diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs index 9c492583f..dadf50839 100644 --- a/apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs +++ b/apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs @@ -188,9 +188,14 @@ pub(crate) async fn scheduler_ordering_config_for_routing_policy( state: PlannerAppState<'_>, routing_policy: Option<&ResolvedRoutingPolicy>, ) -> SchedulerOrderingConfig { + let system_config = read_scheduler_ordering_config_or_default(state).await; match routing_policy { - Some(policy) => scheduler_ordering_config_from_routing_policy(policy), - None => read_scheduler_ordering_config_or_default(state).await, + Some(policy) => { + let mut config = scheduler_ordering_config_from_routing_policy(policy); + config.keep_priority_on_conversion |= system_config.keep_priority_on_conversion; + config + } + None => system_config, } } @@ -390,6 +395,43 @@ mod tests { assert_eq!(overlaid.key_global_priority_for_format, Some(2)); } + #[tokio::test] + async fn routing_policy_inherits_global_conversion_priority_override() { + let data_state = GatewayDataState::default().with_system_config_values_for_tests([( + "keep_priority_on_conversion".to_string(), + json!(true), + )]); + let state = AppState::new() + .expect("state should build") + .with_data_state_for_tests(data_state); + let policy = aether_routing_core::ResolvedRoutingPolicy { + group_id: Some("group-1".to_string()), + group_version: Some(1), + selection_source: "system_default".to_string(), + requested_model: "gpt-5.4-mini".to_string(), + resolved_model: "gpt-5.4-mini".to_string(), + priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider, + scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, + keep_priority_on_conversion: false, + ranking_overlay: Default::default(), + mutation_plan: Default::default(), + pool_policy_overrides: Default::default(), + matched_rules: Vec::new(), + }; + + let ordering = super::scheduler_ordering_config_for_routing_policy( + PlannerAppState::new(&state), + Some(&policy), + ) + .await; + + assert_eq!( + ordering.scheduling_mode, + crate::scheduler::config::SchedulerSchedulingMode::FixedOrder + ); + assert!(ordering.keep_priority_on_conversion); + } + #[test] fn routing_policy_uses_pool_priority_for_pool_group_global_key_slot() { let mut candidate = sample_candidate("endpoint-1", "representative-key"); diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs index d5ad3c92c..cb5fadfa7 100644 --- a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs +++ b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs @@ -2540,4 +2540,146 @@ mod tests { vec!["provider-openai-responses-regular"] ); } + + #[tokio::test] + async fn fixed_order_prefers_codex_responses_when_conversion_keeps_priority() { + let mut codex = standard_candidate_row("provider-codex", "openai:responses", 0); + codex.provider_type = "codex".to_string(); + codex.key_auth_type = "oauth".to_string(); + codex.global_model_name = "gpt-5.4-mini".to_string(); + codex.model_provider_model_name = "gpt-5.4-mini".to_string(); + let mut custom_chat = standard_candidate_row("provider-custom", "openai:chat", 10); + custom_chat.global_model_name = "gpt-5.4-mini".to_string(); + custom_chat.model_provider_model_name = "gpt-5.4-mini".to_string(); + + let candidate_repository: Arc = + Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([ + codex.clone(), + custom_chat.clone(), + ])); + let catalog_items = [ + provider_catalog_for_standard_row(&codex, false), + provider_catalog_for_standard_row(&custom_chat, false), + ]; + let provider_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + catalog_items + .iter() + .map(|(provider, _, _)| provider.clone()) + .collect(), + catalog_items + .iter() + .map(|(_, endpoint, _)| endpoint.clone()) + .collect(), + catalog_items + .iter() + .map(|(_, _, key)| key.clone()) + .collect(), + )); + let data_state = + GatewayDataState::with_provider_catalog_and_minimal_candidate_selection_for_tests( + provider_repository, + candidate_repository, + ) + .with_encryption_key_for_tests("development-key") + .with_system_config_values_for_tests([ + ( + "scheduling_mode".to_string(), + serde_json::json!("fixed_order"), + ), + ( + "keep_priority_on_conversion".to_string(), + serde_json::json!(true), + ), + ]); + let app = AppState::new() + .expect("gateway state should build") + .with_data_state_for_tests(data_state); + let auth_snapshot = unrestricted_auth_snapshot(); + let model_directive_policy = + crate::system_features::ModelDirectivePolicySnapshot::load(&app).await; + let routing_policy = ResolvedRoutingPolicy { + group_id: Some("routing-group-codex-first".to_string()), + group_version: Some(1), + selection_source: "test".to_string(), + requested_model: "gpt-5.4-mini".to_string(), + resolved_model: "gpt-5.4-mini".to_string(), + priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider, + scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder, + keep_priority_on_conversion: false, + ranking_overlay: Default::default(), + mutation_plan: Default::default(), + pool_policy_overrides: Default::default(), + matched_rules: Vec::new(), + }; + let mut cursor = LocalCandidatePreselectionPageCursor::new( + PlannerAppState::new(&app), + &model_directive_policy, + "openai:chat", + "gpt-5.4-mini", + None, + false, + None, + &auth_snapshot, + Some(&routing_policy), + None, + None, + true, + LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat, + false, + None, + ) + .await; + + let first_page = cursor + .next_page() + .await + .expect("preselection should succeed") + .expect("Codex and custom candidates should share the priority page"); + assert!( + first_page.skipped_candidates.is_empty(), + "priority page unexpectedly skipped candidates: {:?}", + first_page + .skipped_candidates + .iter() + .map(|candidate| { + ( + candidate.candidate.provider_id.as_str(), + candidate.skip_reason, + ) + }) + .collect::>() + ); + assert_eq!( + first_page + .candidates + .iter() + .map(|candidate| candidate.provider_id.as_str()) + .collect::>(), + vec!["provider-custom", "provider-codex"] + ); + let (ranked, skipped) = + super::super::candidate_resolution::resolve_and_rank_logical_local_execution_candidates( + PlannerAppState::new(&app), + first_page.candidates, + "openai:chat", + Some("gpt-5.4-mini"), + Some(&auth_snapshot), + None, + None, + Some(&routing_policy), + None, + None, + aether_ai_serving::AiCandidateResolutionMode::Standard, + ) + .await; + + assert!(skipped.is_empty()); + assert_eq!( + ranked + .iter() + .map(|candidate| candidate.candidate.provider_id.as_str()) + .collect::>(), + vec!["provider-codex", "provider-custom"] + ); + } } diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs index 3bd3fab09..2529b107a 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/tests.rs @@ -352,7 +352,7 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() { false, ) .is_some()); - assert!(build_local_openai_responses_request_body( + let remapped = build_local_openai_responses_request_body( &alias, "gpt-5.4", false, @@ -364,14 +364,15 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() { &http::HeaderMap::new(), false, ) - .is_none()); + .expect("explicit reasoning effort should pass through to the mapped model"); + assert_eq!(remapped["reasoning"]["effort"], "max"); let minimal = json!({ "model": "deployment-alias", "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "minimal" }); - assert!(build_local_openai_chat_request_body( + let minimal = build_local_openai_chat_request_body( &minimal, "gpt-5.6-terra", false, @@ -380,7 +381,8 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() { &http::HeaderMap::new(), false, ) - .is_none()); + .expect("explicit chat reasoning effort should be validated by the upstream"); + assert_eq!(minimal["reasoning_effort"], "minimal"); let opaque_mapping = json!({ "model": "gpt-5.6-sol-max", @@ -426,7 +428,7 @@ fn final_openai_provider_contract_validates_body_rule_output() { let model_override = json!([ {"action":"set","path":"model","value":"gpt-5.4"} ]); - assert!(build_local_openai_responses_request_body( + let provider_request = build_local_openai_responses_request_body( &body, "gpt-5.6-sol", false, @@ -438,7 +440,9 @@ fn final_openai_provider_contract_validates_body_rule_output() { &http::HeaderMap::new(), false, ) - .is_none()); + .expect("body rule output should preserve explicit reasoning effort"); + assert_eq!(provider_request["model"], "gpt-5.4"); + assert_eq!(provider_request["reasoning"]["effort"], "max"); let cache_override = json!([ {"action":"set","path":"prompt_cache_options.ttl","value":"1h"} diff --git a/apps/aether-gateway/src/system_features.rs b/apps/aether-gateway/src/system_features.rs index c7d5c2879..163653fae 100644 --- a/apps/aether-gateway/src/system_features.rs +++ b/apps/aether-gateway/src/system_features.rs @@ -804,7 +804,7 @@ mod tests { } #[test] - fn codex_ultra_uses_the_builtin_policy_path_for_default_and_explicit_settings() { + fn codex_ultra_is_not_a_gateway_model_directive() { let snapshots = [ ModelDirectivePolicySnapshot::from_config_values(Some(&json!(true)), None), ModelDirectivePolicySnapshot::from_config_values( @@ -825,18 +825,9 @@ mod tests { ]; for snapshot in snapshots { - for model in ["gpt-5.6-sol", "gpt-5.6-terra"] { - let requested_model = format!("{model}-ultra"); - let resolution = - snapshot.resolve_reasoning("openai:responses", Some(&requested_model)); - assert_eq!(resolution.base_model(), Some(model)); - assert_eq!( - resolution.mapping_patch_for_mapped_model(model), - Ok(Some(json!({ "reasoning": { "effort": "ultra" } }))) - ); - } - for requested_model in [ + "gpt-5.6-sol-ultra", + "gpt-5.6-terra-ultra", "gpt-5.6-ultra", "gpt-5.6-luna-ultra", "gpt-5.4-ultra", diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs index ffec4659e..3e3cfd577 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs @@ -383,7 +383,7 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont .header(TRACE_ID_HEADER, "trace-search-1") .json(&json!({ "id": "session-search-1", - "model": "gpt-5.6-sol-ultra", + "model": "gpt-5.6-sol-max", "reasoning": {"effort": "low", "summary": "auto"}, "input": "find current OpenAI documentation", "commands": { diff --git a/crates/aether-ai/formats/src/formats/openai/reasoning.rs b/crates/aether-ai/formats/src/formats/openai/reasoning.rs index e0632bdb5..5453b1c84 100644 --- a/crates/aether-ai/formats/src/formats/openai/reasoning.rs +++ b/crates/aether-ai/formats/src/formats/openai/reasoning.rs @@ -1,7 +1,5 @@ use serde_json::Value; -use crate::formats::shared::model_directives::ReasoningEffort; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OpenAiReasoningViolationKind { InvalidType, @@ -50,17 +48,15 @@ pub(crate) fn validate_openai_reasoning_request_with_source_model( source_model, body, None, - None, ) } pub(crate) fn validate_openai_reasoning_request_with_model_profile( source_api_format: &str, - provider_api_format: &str, + _provider_api_format: &str, provider_model: &str, source_model: &str, body: &Value, - model_card_reasoning_efforts: Option<&[String]>, supports_reasoning_mode: Option, ) -> Result<(), OpenAiReasoningContractViolation> { let Some(object) = body.as_object() else { @@ -102,14 +98,7 @@ pub(crate) fn validate_openai_reasoning_request_with_model_profile( _ => None, }; if let Some(value) = effort.filter(|value| !value.is_null()) { - validate_reasoning_effort( - value, - source_api_format.as_str(), - provider_api_format, - provider_model, - source_model, - model_card_reasoning_efforts, - )?; + validate_reasoning_effort(value, source_api_format.as_str())?; } if source_api_format != "openai:search" { @@ -139,10 +128,6 @@ pub(crate) fn validate_openai_reasoning_request_with_model_profile( fn validate_reasoning_effort( value: &Value, source_api_format: &str, - provider_api_format: &str, - provider_model: &str, - source_model: &str, - model_card_reasoning_efforts: Option<&[String]>, ) -> Result<(), OpenAiReasoningContractViolation> { let field = if source_api_format == "openai:chat" { "reasoning_effort" @@ -165,48 +150,7 @@ fn validate_reasoning_effort( reason: "reasoning effort must not be empty".to_string(), }); } - if raw.trim() == "ultra" { - return Err(OpenAiReasoningContractViolation { - kind: OpenAiReasoningViolationKind::InvalidEnum, - field: field.to_string(), - value: Some(raw.to_string()), - reason: "ultra is a Codex client preset, not an OpenAI wire effort".to_string(), - }); - } - if let Some(supported_efforts) = - model_card_reasoning_efforts.filter(|values| !values.is_empty()) - { - if supported_efforts - .iter() - .any(|effort| effort == raw.trim() || (raw.trim() == "max" && effort == "ultra")) - { - return Ok(()); - } - return Err(OpenAiReasoningContractViolation { - kind: OpenAiReasoningViolationKind::UnsupportedForModel, - field: field.to_string(), - value: Some(raw.to_string()), - reason: "provider model card does not support the requested reasoning effort" - .to_string(), - }); - } - let Some(effort) = ReasoningEffort::parse(raw) else { - return Ok(()); - }; - if crate::reasoning_effort_supported_for_model( - provider_api_format, - provider_model, - source_model, - effort, - ) { - return Ok(()); - } - Err(OpenAiReasoningContractViolation { - kind: OpenAiReasoningViolationKind::UnsupportedForModel, - field: field.to_string(), - value: Some(raw.to_string()), - reason: "provider model does not support the requested reasoning effort".to_string(), - }) + Ok(()) } fn validate_reasoning_mode( @@ -303,69 +247,32 @@ mod tests { use super::{validate_openai_reasoning_request, OpenAiReasoningViolationKind}; #[test] - fn mapped_model_is_authoritative_for_openai_reasoning_effort() { - let alias = json!({ - "model": "deployment-alias", - "reasoning": {"effort": "max"} - }); - validate_openai_reasoning_request( - "openai:responses", - "openai:responses", - "gpt-5.6-sol", - &alias, - ) - .expect("GPT-5.6 should accept max"); - - let error = validate_openai_reasoning_request( - "openai:responses", - "openai:responses", - "gpt-5.4", - &alias, - ) - .expect_err("GPT-5.4 should reject max"); - assert_eq!( - error.kind, - OpenAiReasoningViolationKind::UnsupportedForModel - ); - } - - #[test] - fn gpt_5_6_rejects_known_unsupported_effort_and_preserves_custom_effort() { - let unsupported = json!({ - "model": "gpt-5.6-terra", - "reasoning_effort": "minimal" - }); - let error = validate_openai_reasoning_request( - "openai:chat", - "openai:chat", - "gpt-5.6-terra", - &unsupported, - ) - .expect_err("known unsupported effort should be rejected"); - assert_eq!( - error.kind, - OpenAiReasoningViolationKind::UnsupportedForModel - ); - - validate_openai_reasoning_request( - "openai:responses", - "openai:responses", - "gpt-5.6-terra", - &json!({ - "model": "gpt-5.6-terra", - "reasoning": {"effort": "future"} - }), - ) - .expect("model-advertised custom effort should pass through"); - - let ultra = validate_openai_reasoning_request( - "openai:responses", - "openai:responses", - "gpt-5.6-terra", - &json!({"reasoning": {"effort": "ultra"}}), - ) - .expect_err("Codex local ultra preset should not enter the OpenAI wire contract"); - assert_eq!(ultra.kind, OpenAiReasoningViolationKind::InvalidEnum); + fn reasoning_effort_support_is_deferred_to_upstream() { + for (format, body, provider_model) in [ + ( + "openai:responses", + json!({"reasoning": {"effort": "max"}}), + "gpt-5.4", + ), + ( + "openai:chat", + json!({"reasoning_effort": "minimal"}), + "gpt-5.6-terra", + ), + ( + "openai:responses", + json!({"reasoning": {"effort": "future"}}), + "gpt-5.6-terra", + ), + ( + "openai:responses", + json!({"reasoning": {"effort": "ultra"}}), + "gpt-5.6-terra", + ), + ] { + validate_openai_reasoning_request(format, format, provider_model, &body) + .expect("well-formed reasoning efforts should be validated by the upstream"); + } let empty = validate_openai_reasoning_request( "openai:responses", diff --git a/crates/aether-ai/formats/src/formats/openai/request_contract.rs b/crates/aether-ai/formats/src/formats/openai/request_contract.rs index deeeb68f6..9510d7888 100644 --- a/crates/aether-ai/formats/src/formats/openai/request_contract.rs +++ b/crates/aether-ai/formats/src/formats/openai/request_contract.rs @@ -76,11 +76,6 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities( ) } } - super::responses::codex::normalize_codex_openai_reasoning_wire_effort( - body, - finalization.provider_type, - finalization.provider_api_format, - ); super::responses::codex::apply_openai_responses_compact_special_body_edits( body, finalization.provider_api_format, @@ -108,12 +103,11 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities( finalization.provider_api_format, ) .map_err(OpenAiProviderRequestContractViolation::CodexCompact)?; - validate_openai_provider_request_contract_with_codex_model_capabilities( + validate_final_openai_provider_request_contract( finalization.provider_api_format, provider_model, finalization.source_model, body, - model_capabilities, ) } @@ -123,21 +117,19 @@ pub fn validate_openai_provider_request_contract( source_model: &str, body: &Value, ) -> Result<(), OpenAiProviderRequestContractViolation> { - validate_openai_provider_request_contract_with_codex_model_capabilities( + validate_final_openai_provider_request_contract( provider_api_format, provider_model, source_model, body, - None, ) } -fn validate_openai_provider_request_contract_with_codex_model_capabilities( +fn validate_final_openai_provider_request_contract( provider_api_format: &str, provider_model: &str, source_model: &str, body: &Value, - model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>, ) -> Result<(), OpenAiProviderRequestContractViolation> { super::responses::request::validate_openai_responses_request_contract( body, @@ -157,7 +149,6 @@ fn validate_openai_provider_request_contract_with_codex_model_capabilities( provider_model, source_model, body, - model_capabilities.map(|capabilities| capabilities.supported_reasoning_efforts.as_slice()), None, ) .map_err(OpenAiProviderRequestContractViolation::Reasoning) @@ -224,14 +215,14 @@ mod tests { } #[test] - fn codex_finalization_enforces_model_card_reasoning_efforts() { + fn codex_finalization_defers_explicit_reasoning_effort_to_upstream() { let mut body = json!({ "model": "gpt-5.6-sol", "input": [], "reasoning": {"effort": "minimal"} }); - let error = finalize_openai_provider_request( + finalize_openai_provider_request( &mut body, OpenAiProviderRequestFinalization { source_api_format: "openai:responses", @@ -244,17 +235,35 @@ mod tests { require_body_stream_field: true, }, ) - .expect_err("GPT-5.6 Codex model card should reject minimal"); + .expect("explicit reasoning effort support should be validated by the upstream"); - assert!(matches!( - error, - super::OpenAiProviderRequestContractViolation::Reasoning( - super::OpenAiReasoningContractViolation { - kind: crate::formats::openai::reasoning::OpenAiReasoningViolationKind::UnsupportedForModel, - .. - } - ) - )); + assert_eq!(body["reasoning"]["effort"], "minimal"); + } + + #[test] + fn codex_finalization_accepts_none_for_gpt_5_4_mini() { + let mut body = json!({ + "model": "gpt-5.4-mini", + "input": [{"role": "user", "content": "hello"}], + "reasoning": {"effort": "none"} + }); + + finalize_openai_provider_request( + &mut body, + OpenAiProviderRequestFinalization { + source_api_format: "openai:responses", + provider_api_format: "openai:responses", + provider_type: "codex", + provider_model: "gpt-5.4-mini", + source_model: "gpt-5.4-mini", + body_rules: None, + upstream_is_stream: false, + require_body_stream_field: true, + }, + ) + .expect("GPT-5.4 mini should accept the official none reasoning effort"); + + assert_eq!(body["reasoning"]["effort"], "none"); } #[test] @@ -364,17 +373,7 @@ mod tests { } #[test] - fn codex_ultra_preset_uses_max_for_every_codex_model_on_the_wire() { - let mut sol = json!({ - "model": "gpt-5.6-sol", - "input": [], - "reasoning": {"effort": "ultra"} - }); - let mut luna = json!({ - "model": "gpt-5.6-luna", - "input": [], - "reasoning": {"effort": "ultra"} - }); + fn codex_finalization_preserves_explicit_reasoning_effort() { let finalization_for = |model| OpenAiProviderRequestFinalization { source_api_format: "openai:responses", provider_api_format: "openai:responses", @@ -386,17 +385,20 @@ mod tests { require_body_stream_field: true, }; - finalize_openai_provider_request(&mut sol, finalization_for("gpt-5.6-sol")) - .expect("Sol ultra preset should map to the OpenAI wire contract"); - assert_eq!(sol["reasoning"]["effort"], "max"); - - finalize_openai_provider_request(&mut luna, finalization_for("gpt-5.6-luna")) - .expect("Luna ultra preset should map to the OpenAI wire contract"); - assert_eq!(luna["reasoning"]["effort"], "max"); + for (model, effort) in [("gpt-5.6-sol", "max"), ("gpt-5.6-luna", "ultra")] { + let mut body = json!({ + "model": model, + "input": [], + "reasoning": {"effort": effort} + }); + finalize_openai_provider_request(&mut body, finalization_for(model)) + .expect("explicit effort support should be validated by the upstream"); + assert_eq!(body["reasoning"]["effort"], effort); + } } #[test] - fn dynamic_codex_card_controls_default_effort_and_keeps_mode_model_specific() { + fn dynamic_codex_card_preserves_default_effort_and_keeps_mode_model_specific() { let finalization = OpenAiProviderRequestFinalization { source_api_format: "openai:responses", provider_api_format: "openai:responses", @@ -425,8 +427,8 @@ mod tests { finalization, Some(&capabilities), ) - .expect("card default ultra should use the max wire effort"); - assert_eq!(default_body["reasoning"]["effort"], "max"); + .expect("card default effort should be preserved for the upstream"); + assert_eq!(default_body["reasoning"]["effort"], "ultra"); let mut mode_body = json!({ "model": "gpt-5.7-sol", @@ -458,12 +460,12 @@ mod tests { finalization, Some(&ultra_only), ) - .expect("Codex maps the Ultra preset to max without card-list wire validation"); - assert_eq!(ultra_only_body["reasoning"]["effort"], "max"); + .expect("explicit effort support should be validated by the upstream"); + assert_eq!(ultra_only_body["reasoning"]["effort"], "ultra"); } #[test] - fn codex_search_normalizes_reasoning_and_projects_the_typed_request() { + fn codex_search_projects_wire_reasoning_and_the_typed_request() { let finalization = OpenAiProviderRequestFinalization { source_api_format: "openai:search", provider_api_format: "openai:search", @@ -478,7 +480,7 @@ mod tests { "id": "session-1", "model": "gpt-5.6-sol", "reasoning": { - "effort": "ultra", + "effort": "max", "summary": "auto", "context": "current_turn", "future_reasoning_field": true @@ -505,7 +507,7 @@ mod tests { } #[test] - fn codex_search_validates_reasoning_effort_against_model_card() { + fn codex_search_defers_explicit_reasoning_effort_to_upstream() { let finalization = OpenAiProviderRequestFinalization { source_api_format: "openai:search", provider_api_format: "openai:search", @@ -524,17 +526,14 @@ mod tests { finalize_openai_provider_request(&mut supported, finalization) .expect("published Search effort should pass"); - let mut unsupported = json!({ + let mut unpublished = json!({ "id": "session-1", "model": "gpt-5.6-sol", "reasoning": {"effort": "none"} }); - let error = finalize_openai_provider_request(&mut unsupported, finalization) - .expect_err("unpublished Search effort should be rejected"); - assert!(matches!( - error, - super::OpenAiProviderRequestContractViolation::Reasoning(_) - )); + finalize_openai_provider_request(&mut unpublished, finalization) + .expect("unpublished Search effort support should be validated by the upstream"); + assert_eq!(unpublished["reasoning"]["effort"], "none"); } #[test] @@ -575,32 +574,26 @@ mod tests { "input": [], "reasoning": {"effort": "vendoreffortx"} }); - let error = finalize_openai_provider_request_with_codex_model_capabilities( + finalize_openai_provider_request_with_codex_model_capabilities( &mut custom, finalization, Some(&capabilities), ) - .expect_err("custom reasoning efforts should match the model card exactly"); - assert!(matches!( - error, - super::OpenAiProviderRequestContractViolation::Reasoning(_) - )); + .expect("explicit custom effort support should be validated by the upstream"); + assert_eq!(custom["reasoning"]["effort"], "vendoreffortx"); let mut ultra = json!({ "model": "codex-custom", "input": [], "reasoning": {"effort": "ultra"} }); - let error = finalize_openai_provider_request_with_codex_model_capabilities( + finalize_openai_provider_request_with_codex_model_capabilities( &mut ultra, finalization, Some(&capabilities), ) - .expect_err("ultra should require model-card support before mapping to max"); - assert!(matches!( - error, - super::OpenAiProviderRequestContractViolation::Reasoning(_) - )); + .expect("explicit effort support should be validated by the upstream"); + assert_eq!(ultra["reasoning"]["effort"], "ultra"); } #[test] diff --git a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs index c7841e6dc..a55ed0d7f 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/codex.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/codex.rs @@ -176,6 +176,16 @@ pub fn codex_responses_model_capabilities_from_card( }) .collect(); } + if model_id == "gpt-5.4-mini" { + capabilities.default_reasoning_effort = Some("none".to_string()); + if !capabilities.supported_reasoning_efforts.is_empty() + && !capabilities.supports_reasoning_effort("none") + { + capabilities + .supported_reasoning_efforts + .insert(0, "none".to_string()); + } + } if let Some(value) = card .get("supports_parallel_tool_calls") .and_then(Value::as_bool) @@ -501,10 +511,10 @@ pub fn bundled_codex_model_cards() -> &'static [Value] { model_id: "gpt-5.4-mini", display_name: "GPT-5.4 Mini", description: "Small, fast, and cost-efficient model for simpler coding tasks.", - default_reasoning_level: "medium", + default_reasoning_level: "none", default_reasoning_summary: "none", use_responses_lite: false, - efforts: &["low", "medium", "high", "xhigh"], + efforts: &["none", "low", "medium", "high", "xhigh"], default_verbosity: "medium", supports_priority_tier: false, }), @@ -1207,39 +1217,6 @@ fn remove_codex_reasoning_summary_delivery(body_object: &mut serde_json::Map) { - let Some(reasoning) = body_object - .get_mut("reasoning") - .and_then(Value::as_object_mut) - else { - return; - }; - let is_ultra = reasoning - .get("effort") - .and_then(Value::as_str) - .is_some_and(|effort| effort == "ultra"); - if is_ultra { - reasoning.insert("effort".to_string(), json!("max")); - } -} - -pub fn normalize_codex_openai_reasoning_wire_effort( - provider_request_body: &mut Value, - provider_type: &str, - provider_api_format: &str, -) { - if !provider_type.trim().eq_ignore_ascii_case("codex") - || !(aether_ai_formats::is_openai_responses_family_format(provider_api_format) - || aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:search")) - { - return; - } - let Some(body_object) = provider_request_body.as_object_mut() else { - return; - }; - normalize_codex_reasoning_effort(body_object); -} - fn is_codex_responses_lite_additional_tools_item(value: &Value) -> bool { value .get("type") @@ -1777,7 +1754,6 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap supports_reasoning_mode, body_rules, ); - normalize_codex_reasoning_effort(body_object); apply_codex_model_request_capabilities( body_object, provider_api_format, @@ -2071,7 +2047,7 @@ mod tests { "instructions": "Use the tools.", "input": [{"id": "msg-1", "type": "message", "role": "user", "content": []}], "tools": [{"type": "function", "name": "lookup"}], - "reasoning": {"effort": "ultra"}, + "reasoning": {"effort": "max"}, "parallel_tool_calls": true, "service_tier": "priority", "text": {"format": {"type": "json_schema"}}, @@ -2254,7 +2230,7 @@ mod tests { } #[test] - fn ultra_reasoning_always_uses_max_on_the_provider_wire() { + fn special_body_edits_do_not_rewrite_explicit_ultra_effort() { for provider_api_format in ["openai:responses", "openai:responses:compact"] { let mut body = json!({ "model": "gpt-5.6-luna", @@ -2270,7 +2246,7 @@ mod tests { None, None, ); - assert_eq!(body["reasoning"]["effort"], "max"); + assert_eq!(body["reasoning"]["effort"], "ultra"); } let mut custom = json!({ @@ -2472,6 +2448,46 @@ mod tests { } } + #[test] + fn bundled_gpt_5_4_mini_supports_official_reasoning_efforts() { + let capabilities = + resolve_codex_responses_model_capabilities("gpt-5.4-mini", "gpt-5.4-mini", None); + + assert_eq!( + capabilities.default_reasoning_effort.as_deref(), + Some("none") + ); + assert_eq!( + capabilities.supported_reasoning_efforts, + vec!["none", "low", "medium", "high", "xhigh"] + ); + assert!(capabilities.supports_reasoning_effort("none")); + + let metadata = build_codex_model_catalog_metadata(&[json!({ + "slug": "gpt-5.4-mini", + "default_reasoning_level": "medium", + "supported_reasoning_levels": [ + {"effort": "low"}, + {"effort": "medium"}, + {"effort": "high"}, + {"effort": "xhigh"} + ] + })]); + let cached_capabilities = resolve_codex_responses_model_capabilities( + "gpt-5.4-mini", + "gpt-5.4-mini", + Some(&metadata), + ); + assert_eq!( + cached_capabilities.default_reasoning_effort.as_deref(), + Some("none") + ); + assert_eq!( + cached_capabilities.supported_reasoning_efforts, + vec!["none", "low", "medium", "high", "xhigh"] + ); + } + #[test] fn model_card_preserves_custom_reasoning_effort_values() { let metadata = build_codex_model_catalog_metadata(&[json!({ @@ -2806,6 +2822,56 @@ mod tests { ); } + #[test] + fn codex_responses_body_edits_preserve_explicit_system_messages() { + let explicit_system_message = json!({ + "type": "message", + "role": "system", + "content": [{ + "type": "input_text", + "text": "Keep this caller-supplied role." + }] + }); + + let mut standard_body = json!({ + "model": "gpt-5.4", + "instructions": "Standard base instructions.", + "input": [explicit_system_message.clone()] + }); + apply_codex_openai_responses_special_body_edits( + &mut standard_body, + "codex", + "openai:responses", + None, + None, + ); + + assert_eq!(standard_body["instructions"], "Standard base instructions."); + assert_eq!(standard_body["input"], json!([explicit_system_message])); + + let mut lite_body = json!({ + "model": "gpt-5.6-sol", + "instructions": "Lite base instructions.", + "input": [explicit_system_message.clone()] + }); + apply_codex_openai_responses_special_body_edits( + &mut lite_body, + "codex", + "openai:responses", + None, + None, + ); + + assert!(lite_body.get("instructions").is_none()); + assert_eq!(lite_body["input"][0]["type"], "additional_tools"); + assert_eq!(lite_body["input"][1]["role"], "developer"); + assert_eq!( + lite_body["input"][1]["content"][0]["text"], + "Lite base instructions." + ); + assert_eq!(lite_body["input"][2], explicit_system_message); + } + #[test] fn codex_responses_body_edits_preserve_function_tools_for_codex_backend() { let mut provider_request_body = json!({ diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 64d632615..ba9846a8e 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -4763,7 +4763,7 @@ mod tests { } #[test] - fn runtime_reasoning_effort_uses_concrete_mapped_model_as_authoritative_capability() { + fn runtime_reasoning_effort_is_preserved_across_concrete_model_mapping() { let alias_to_gpt_5_6 = json!({ "model": "deployment-alias", "messages": [{"role": "user", "content": "hello"}], @@ -4784,22 +4784,19 @@ mod tests { "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "max" }); - let error = convert_request( + let converted = convert_request( "openai:chat", "openai:responses", &gpt_5_6_to_gpt_5_4, &FormatContext::default().with_mapped_model("gpt-5.4"), ) - .expect_err("a concrete GPT-5.4 mapped target must reject max"); - assert!(matches!( - error, - super::FormatError::InvalidTargetField { ref field, .. } - if field == "reasoning_effort" - )); + .expect("mapped model capability cards must not reject explicit efforts"); + assert_eq!(converted["model"], "gpt-5.4"); + assert_eq!(converted["reasoning"]["effort"], "max"); } #[test] - fn pure_conversion_reasoning_effort_uses_the_concrete_mapped_model() { + fn pure_conversion_preserves_effort_across_concrete_model_mapping() { let alias_to_gpt_5_6 = json!({ "model": "deployment-alias", "messages": [{"role": "user", "content": "hello"}], @@ -4819,40 +4816,32 @@ mod tests { "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "max" }); - let error = convert_request_pure_with_context( + let converted = convert_request_pure_with_context( "openai:chat", "openai:responses", &gpt_5_6_to_gpt_5_4, &FormatContext::default().with_mapped_model("gpt-5.4"), ) - .expect_err("a concrete GPT-5.4 target must reject max"); - assert!(matches!( - error, - super::FormatError::InvalidTargetField { ref field, .. } - if field == "reasoning_effort" - )); + .expect("mapped model capability cards must not reject explicit efforts"); + assert_eq!(converted.value["reasoning"]["effort"], "max"); } #[test] - fn runtime_openai_cross_format_enforces_known_efforts_and_preserves_custom_efforts() { + fn runtime_openai_cross_format_preserves_explicit_efforts() { let alias_minimal = json!({ "model": "deployment-alias", "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "minimal" }); for target in ["openai:responses", "openai:responses:compact"] { - let error = convert_request( + let converted = convert_request( "openai:chat", target, &alias_minimal, &FormatContext::default().with_mapped_model("gpt-5.6-terra"), ) - .expect_err("mapped GPT-5.6 deployments must reject minimal effort"); - assert!(matches!( - error, - super::FormatError::InvalidTargetField { ref field, .. } - if field == "reasoning_effort" - )); + .expect("mapped model capability cards must not reject explicit efforts"); + assert_eq!(converted["reasoning"]["effort"], "minimal"); } let custom = json!({ @@ -4877,18 +4866,14 @@ mod tests { "reasoning": {"effort": "ultra"} }); for source in ["openai:responses", "openai:responses:compact"] { - let error = convert_request( + let converted = convert_request( source, "openai:chat", &ultra, &FormatContext::default().with_mapped_model("gpt-5.6-sol"), ) - .expect_err("Codex local ultra preset should not enter the OpenAI wire contract"); - assert!(matches!( - error, - super::FormatError::InvalidEnumValue { ref field, ref value, .. } - if field == "reasoning.effort" && value == "ultra" - )); + .expect("explicit effort support should be validated by the upstream"); + assert_eq!(converted["reasoning_effort"], "ultra"); } } @@ -4909,42 +4894,30 @@ mod tests { "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "ultra" }); - let error = convert_request_pure("openai:chat", "openai:responses", &ultra) - .expect_err("Codex local ultra preset should not enter the OpenAI wire contract"); - assert!(matches!( - error, - super::FormatError::InvalidEnumValue { ref field, ref value, .. } - if field == "reasoning_effort" && value == "ultra" - )); + let converted = convert_request_pure("openai:chat", "openai:responses", &ultra) + .expect("explicit effort support should be validated by the upstream"); + assert_eq!(converted.value["reasoning"]["effort"], "ultra"); } #[test] - fn pure_openai_cross_format_rejects_gpt_5_6_minimal_effort() { + fn pure_openai_cross_format_preserves_unpublished_effort() { let chat = json!({ "model": "gpt-5.6-sol", "messages": [{"role": "user", "content": "hello"}], "reasoning_effort": "minimal" }); - let error = convert_request_pure("openai:chat", "openai:responses", &chat) - .expect_err("GPT-5.6 does not publish minimal as a supported effort"); - assert!(matches!( - error, - super::FormatError::InvalidTargetField { ref field, .. } - if field == "reasoning_effort" - )); + let converted = convert_request_pure("openai:chat", "openai:responses", &chat) + .expect("upstream should validate unpublished reasoning efforts"); + assert_eq!(converted.value["reasoning"]["effort"], "minimal"); let responses = json!({ "model": "gpt-5.6-terra", "input": [{"role": "user", "content": "hello"}], "reasoning": {"effort": "minimal"} }); - let error = convert_request_pure("openai:responses", "openai:chat", &responses) - .expect_err("GPT-5.6 does not publish minimal as a supported effort"); - assert!(matches!( - error, - super::FormatError::InvalidTargetField { ref field, .. } - if field == "reasoning.effort" - )); + let converted = convert_request_pure("openai:responses", "openai:chat", &responses) + .expect("upstream should validate unpublished reasoning efforts"); + assert_eq!(converted.value["reasoning_effort"], "minimal"); } #[test] diff --git a/crates/aether-ai/formats/src/formats/shared/model_directives.rs b/crates/aether-ai/formats/src/formats/shared/model_directives.rs index 221680d7b..c71782c18 100644 --- a/crates/aether-ai/formats/src/formats/shared/model_directives.rs +++ b/crates/aether-ai/formats/src/formats/shared/model_directives.rs @@ -8,12 +8,11 @@ pub const MODEL_DIRECTIVE_API_FORMATS: [&str; 6] = [ "claude:messages", "gemini:generate_content", ]; -pub const OPENAI_MODEL_DIRECTIVE_SUFFIXES: [&str; 9] = [ - "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "fast", -]; -const OPENAI_SEARCH_MODEL_DIRECTIVE_SUFFIXES: [&str; 8] = [ - "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", +pub const OPENAI_MODEL_DIRECTIVE_SUFFIXES: [&str; 8] = [ + "none", "minimal", "low", "medium", "high", "xhigh", "max", "fast", ]; +const OPENAI_SEARCH_MODEL_DIRECTIVE_SUFFIXES: [&str; 7] = + ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; pub const CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; @@ -32,7 +31,6 @@ pub struct ModelDirectiveSuffixResolution { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelOverride { ReasoningEffort(ReasoningEffort), - CodexReasoningPreset(CodexReasoningPreset), ServiceTier(ServiceTier), } @@ -40,7 +38,6 @@ impl ModelOverride { pub fn suffix(&self) -> &'static str { match self { Self::ReasoningEffort(effort) => effort.as_str(), - Self::CodexReasoningPreset(preset) => preset.as_str(), Self::ServiceTier(tier) => tier.as_directive_suffix(), } } @@ -136,19 +133,6 @@ impl ReasoningEffort { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CodexReasoningPreset { - Ultra, -} - -impl CodexReasoningPreset { - pub fn as_str(self) -> &'static str { - match self { - Self::Ultra => "ultra", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ServiceTier { Priority, @@ -288,16 +272,12 @@ fn parse_model_override(suffix: &str) -> Option { } fn parse_model_override_for_model(suffix: &str, model: &str) -> Option { - if suffix.eq_ignore_ascii_case("ultra") && codex_ultra_preset_supported_for_model(model) { - return Some(ModelOverride::CodexReasoningPreset( - CodexReasoningPreset::Ultra, - )); - } + let _ = model; parse_model_override(suffix) } pub fn model_directive_suffix_has_builtin_mapping(suffix: &str) -> bool { - parse_model_override(suffix).is_some() || suffix.eq_ignore_ascii_case("ultra") + parse_model_override(suffix).is_some() } pub fn model_directive_builtin_suffix_supported_for_source_model( @@ -308,14 +288,7 @@ pub fn model_directive_builtin_suffix_supported_for_source_model( } fn model_directive_suffix_is_reasoning(suffix: &str) -> bool { - ReasoningEffort::parse(suffix).is_some() || suffix.eq_ignore_ascii_case("ultra") -} - -fn codex_ultra_preset_supported_for_model(model: &str) -> bool { - crate::formats::openai::responses::codex::resolve_codex_responses_model_capabilities( - model, model, None, - ) - .supports_reasoning_effort("ultra") + ReasoningEffort::parse(suffix).is_some() } pub fn model_directive_base_model(model: &str) -> Option { @@ -389,14 +362,6 @@ pub fn apply_model_directive_overrides_from_model( )?; applied_override = true; } - ModelOverride::CodexReasoningPreset(preset) => { - apply_codex_reasoning_preset_override( - &mut patched_body, - provider_api_format, - *preset, - )?; - applied_override = true; - } ModelOverride::ServiceTier(tier) => { if is_openai_search { continue; @@ -437,9 +402,6 @@ pub fn default_model_directive_mapping_patch( source_model, effort, )?, - ModelOverride::CodexReasoningPreset(preset) => { - apply_codex_reasoning_preset_override(&mut patch, provider_api_format, preset)? - } ModelOverride::ServiceTier(tier) => { apply_service_tier_override(&mut patch, provider_api_format, tier)? } @@ -533,29 +495,6 @@ fn apply_reasoning_effort_override( } } -fn apply_codex_reasoning_preset_override( - provider_request_body: &mut Value, - provider_api_format: &str, - preset: CodexReasoningPreset, -) -> Option<()> { - match crate::normalize_api_format_alias(provider_api_format).as_str() { - "openai:chat" => { - set_object_string(provider_request_body, "reasoning_effort", preset.as_str()) - } - "openai:responses" | "openai:responses:compact" | "openai:search" => { - let object = provider_request_body.as_object_mut()?; - let reasoning = object - .entry("reasoning".to_string()) - .or_insert_with(|| json!({})); - reasoning - .as_object_mut()? - .insert("effort".to_string(), json!(preset.as_str())); - Some(()) - } - _ => None, - } -} - fn apply_service_tier_override( provider_request_body: &mut Value, provider_api_format: &str, @@ -877,9 +816,8 @@ mod tests { use super::{ apply_model_directive_overrides_from_model, default_model_directive_suffixes, default_model_directives_config, parse_model_directive, - parse_model_directive_with_suffixes, CodexReasoningPreset, ModelDirective, - ModelDirectiveSuffixResolution, ModelOverride, ReasoningEffort, ServiceTier, - MODEL_DIRECTIVE_API_FORMATS, + parse_model_directive_with_suffixes, ModelDirective, ModelDirectiveSuffixResolution, + ModelOverride, ReasoningEffort, ServiceTier, MODEL_DIRECTIVE_API_FORMATS, }; #[test] @@ -1023,18 +961,9 @@ mod tests { } #[test] - fn parses_gpt_5_6_ultra_as_an_internal_reasoning_preset() { - assert_eq!( - parse_model_directive("gpt-5.6-sol-ultra"), - Some(ModelDirective { - base_model: "gpt-5.6-sol".to_string(), - overrides: vec![ModelOverride::CodexReasoningPreset( - CodexReasoningPreset::Ultra, - )], - }) - ); - + fn does_not_treat_ultra_as_a_builtin_model_directive() { for model in [ + "gpt-5.6-sol-ultra", "gpt-5.6-ultra", "gpt-5.6-luna-ultra", "gpt-5.4-ultra", @@ -1042,15 +971,6 @@ mod tests { ] { assert_eq!(parse_model_directive(model), None); } - - let mut unsupported = json!({"model": "gpt-5.4"}); - assert!(apply_model_directive_overrides_from_model( - &mut unsupported, - "openai:responses", - "gpt-5.4", - "gpt-5.4-ultra", - ) - .is_none()); } #[test] diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index e6ad2e912..7a485d2eb 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -27,6 +27,7 @@ pub struct StreamingStandardFormatMatrix { provider: Option, client: Option, propagated_actual_service_tier: Option, + pending_sse_event: Option, terminated: bool, history_recorded: bool, pending_history_record: Option, @@ -42,6 +43,7 @@ impl StreamingStandardFormatMatrix { return Ok(Vec::new()); } self.ensure_initialized(report_context); + let line = self.apply_sse_event_type(line); if let Some(error_body) = build_client_error_body_for_line(report_context, &line) { self.terminated = true; return self.emit_error(error_body); @@ -64,6 +66,10 @@ impl StreamingStandardFormatMatrix { self.emit_frames(report_context, frames) } + fn apply_sse_event_type(&mut self, line: Vec) -> Vec { + apply_sse_event_type(&mut self.pending_sse_event, line) + } + pub fn finish(&mut self, report_context: &Value) -> Result, AiSurfaceFinalizeError> { if self.terminated { return Ok(Vec::new()); @@ -172,10 +178,46 @@ impl StreamingStandardFormatMatrix { } } +fn apply_sse_event_type(pending_sse_event: &mut Option, line: Vec) -> Vec { + let Ok(text) = std::str::from_utf8(&line) else { + return line; + }; + let trimmed = text.trim_matches('\r').trim(); + if let Some(event) = trimmed.strip_prefix("event:").map(str::trim) { + *pending_sse_event = (!event.is_empty()).then(|| event.to_string()); + return line; + } + if trimmed.is_empty() { + *pending_sse_event = None; + return line; + } + if !trimmed.starts_with("data:") { + return line; + } + let Some(event) = pending_sse_event.take() else { + return line; + }; + let Some(mut payload) = decode_json_data_line(&line) else { + return line; + }; + let Some(payload) = payload.as_object_mut() else { + return line; + }; + if payload.contains_key("type") { + return line; + } + payload.insert("type".to_string(), Value::String(event)); + let mut normalized = b"data: ".to_vec(); + normalized.extend(serde_json::to_vec(&payload).expect("JSON value serialization cannot fail")); + normalized.push(b'\n'); + normalized +} + #[derive(Default)] pub struct StreamingStandardTerminalObserver { provider: Option, latest_summary: Option, + pending_sse_event: Option, } impl StreamingStandardTerminalObserver { @@ -185,6 +227,7 @@ impl StreamingStandardTerminalObserver { line: Vec, ) -> Result<(), AiSurfaceFinalizeError> { self.ensure_initialized(report_context); + let line = apply_sse_event_type(&mut self.pending_sse_event, line); let Some(provider) = self.provider.as_mut() else { return Ok(()); }; @@ -671,6 +714,72 @@ mod tests { .collect() } + fn event_only_line(event: &str) -> Vec { + format!("event: {event}\n").into_bytes() + } + + #[test] + fn event_only_stream_types_convert_across_standard_formats() { + let responses_payload = json!({ + "response": { + "id": "resp_event_only_123", + "model": "gpt-5.4", + "status": "in_progress", + "output": [], + }, + }); + + let mut claude_matrix = StreamingStandardFormatMatrix::default(); + let claude_context = report_context("openai:responses", "claude:messages"); + assert!(claude_matrix + .transform_line(&claude_context, event_only_line("response.created")) + .expect("event should parse") + .is_empty()); + let claude_output = claude_matrix + .transform_line(&claude_context, data_line(responses_payload)) + .expect("response.created should convert to Claude"); + assert!(String::from_utf8_lossy(&claude_output).contains("event: message_start")); + + let mut gemini_matrix = StreamingStandardFormatMatrix::default(); + let gemini_context = report_context("openai:responses", "gemini:generate_content"); + gemini_matrix + .transform_line( + &gemini_context, + event_only_line("response.output_text.delta"), + ) + .expect("event should parse"); + let gemini_output = gemini_matrix + .transform_line( + &gemini_context, + data_line(json!({ + "response_id": "resp_event_only_123", + "delta": "hello", + })), + ) + .expect("text delta should convert to Gemini"); + assert!(String::from_utf8_lossy(&gemini_output).contains("\"text\":\"hello\"")); + + let mut chat_matrix = StreamingStandardFormatMatrix::default(); + let chat_context = report_context("claude:messages", "openai:chat"); + chat_matrix + .transform_line(&chat_context, event_only_line("message_start")) + .expect("event should parse"); + let chat_output = chat_matrix + .transform_line( + &chat_context, + data_line(json!({ + "message": { + "id": "msg_event_only_123", + "model": "claude-sonnet-4-5", + }, + })), + ) + .expect("message_start should convert to Chat"); + assert!( + String::from_utf8_lossy(&chat_output).contains("\"delta\":{\"role\":\"assistant\"}") + ); + } + #[test] fn streamed_chat_tool_call_records_responses_continuation_history() { let report_context = json!({ @@ -1983,6 +2092,48 @@ mod tests { assert_eq!(usage.cache_read_tokens, 0); } + #[test] + fn terminal_observer_uses_event_type_when_data_omits_type() { + let report_context = report_context("openai:responses", "openai:chat"); + let mut observer = StreamingStandardTerminalObserver::default(); + + observer + .push_line(&report_context, event_only_line("response.completed")) + .expect("event should parse"); + observer + .push_line( + &report_context, + data_line(json!({ + "response": { + "id": "resp_terminal_event_only_123", + "model": "gpt-5.4", + "status": "completed", + "output": [], + "usage": { + "input_tokens": 2, + "output_tokens": 3, + "total_tokens": 5, + }, + }, + })), + ) + .expect("response.completed should parse"); + + let summary = observer.latest_summary().expect("summary should exist"); + assert!(summary.observed_finish); + assert_eq!( + summary.response_id.as_deref(), + Some("resp_terminal_event_only_123") + ); + assert_eq!( + summary + .standardized_usage + .as_ref() + .map(|usage| usage.input_tokens), + Some(2) + ); + } + #[test] fn terminal_observer_does_not_infer_provider_stream_event_api_format() { let report_context = report_context("openai:chat", "openai:responses"); diff --git a/crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs b/crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs index 5bf216051..b9bd41f56 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs @@ -1056,6 +1056,32 @@ data: {\"type\":\"response.reasoning_summary_text.delta\",\"response_id\":\"resp assert!(!output.contains("data: [DONE]")); } + #[test] + fn standard_rewriter_converts_event_only_response_created_to_chat_role_chunk() { + let report_context = json!({ + "provider_api_format": "openai:responses", + "client_api_format": "openai:chat", + "needs_conversion": true, + }); + let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context)) + .expect("rewriter should exist"); + let event_output = rewriter + .push_chunk(b"event: response.created\n") + .expect("event line should be buffered"); + assert!(event_output.is_empty()); + let output = rewriter + .push_chunk( + b"data: {\"response\":{\"id\":\"resp_created_123\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\",\"output\":[]}}\n\n", + ) + .expect("rewrite should succeed"); + let output = String::from_utf8(output).expect("output should be utf8"); + + assert!(output.contains("\"object\":\"chat.completion.chunk\"")); + assert!(output.contains("\"id\":\"resp_created_123\"")); + assert!(output.contains("\"model\":\"gpt-5.4\"")); + assert!(output.contains("\"delta\":{\"role\":\"assistant\"}")); + } + #[test] fn explicit_responses_stream_format_preserves_function_call_metadata() { let report_context = json!({ diff --git a/frontend/src/features/providers/components/KeyAllowedModelsEditDialog.vue b/frontend/src/features/providers/components/KeyAllowedModelsEditDialog.vue index 7810100c9..8dc0fb2a2 100644 --- a/frontend/src/features/providers/components/KeyAllowedModelsEditDialog.vue +++ b/frontend/src/features/providers/components/KeyAllowedModelsEditDialog.vue @@ -761,7 +761,7 @@ watch([() => props.open, () => props.apiKey], async ([open, apiKey]) => { } else { loadingCancelled = true } -}) +}, { immediate: true }) // 组件卸载时取消所有异步操作 onUnmounted(() => { diff --git a/frontend/src/features/providers/components/__tests__/KeyAllowedModelsEditDialog.loading.spec.ts b/frontend/src/features/providers/components/__tests__/KeyAllowedModelsEditDialog.loading.spec.ts new file mode 100644 index 000000000..992df945d --- /dev/null +++ b/frontend/src/features/providers/components/__tests__/KeyAllowedModelsEditDialog.loading.spec.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, nextTick, type App } from 'vue' + +import KeyAllowedModelsEditDialog from '../KeyAllowedModelsEditDialog.vue' +import type { EndpointAPIKey } from '@/api/endpoints' + +const endpointMocks = vi.hoisted(() => ({ + getProviderModels: vi.fn(), + updateProviderKey: vi.fn(), +})) + +vi.mock('@/api/endpoints', () => endpointMocks) +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }), +})) +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => ({ + confirmWarning: vi.fn().mockResolvedValue(true), + }), +})) +vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({ + useUpstreamModelsCache: () => ({ + fetchModels: vi.fn(), + }), +})) +vi.mock('@/components/ui', async () => { + const { defineComponent, h } = await import('vue') + const Dialog = defineComponent({ + name: 'DialogStub', + setup: (_props, { slots }) => () => h('section', [slots.default?.(), slots.footer?.()]), + }) + const passthrough = (name: string) => defineComponent({ + name, + inheritAttrs: false, + setup: (_props, { attrs, slots }) => () => h('div', attrs, slots.default?.()), + }) + return { + Dialog, + Button: passthrough('ButtonStub'), + Input: passthrough('InputStub'), + } +}) + +const mountedApps: Array<{ app: App, root: HTMLElement }> = [] + +const apiKey: EndpointAPIKey = { + id: 'key-1', + provider_id: 'provider-1', + api_formats: ['openai:chat'], + api_key_masked: 'sk-***', + auth_type: 'api_key', + name: 'Primary key', + internal_priority: 0, + allowed_models: ['gpt-5'], + cache_ttl_minutes: 0, + max_probe_interval_minutes: 5, + health_score: 1, + consecutive_failures: 0, + request_count: 0, + success_count: 0, + error_count: 0, + success_rate: 1, + avg_response_time_ms: 0, + is_active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +} + +async function settle() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve() + await nextTick() + } +} + +beforeEach(() => { + endpointMocks.getProviderModels.mockReset() + endpointMocks.getProviderModels.mockResolvedValue([{ + provider_model_name: 'gpt-5', + global_model_name: 'gpt-5', + global_model_display_name: 'GPT-5', + }]) + endpointMocks.updateProviderKey.mockReset() +}) + +afterEach(() => { + for (const { app, root } of mountedApps.splice(0)) { + app.unmount() + root.remove() + } +}) + +describe('KeyAllowedModelsEditDialog loading', () => { + it('loads saved permissions when lazily mounted in the open state', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(KeyAllowedModelsEditDialog, { + open: true, + apiKey, + providerId: 'provider-1', + }) + app.mount(root) + mountedApps.push({ app, root }) + + await settle() + + expect(endpointMocks.getProviderModels).toHaveBeenCalledOnce() + expect(endpointMocks.getProviderModels).toHaveBeenCalledWith('provider-1', { limit: 1000 }) + expect(root.textContent).toContain('提供商模型') + expect(root.textContent).toContain('已选 1 个') + expect(root.querySelector('.bg-primary.border-primary')).toBeTruthy() + }) +}) \ No newline at end of file