mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
feat(openai): align GPT-5.6 and Codex request contracts
This commit is contained in:
@@ -3,5 +3,29 @@
|
||||
mod tests;
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
|
||||
};
|
||||
|
||||
pub(crate) fn codex_model_capabilities_for_transport(
|
||||
transport: &crate::ai_serving::GatewayProviderTransportSnapshot,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
) -> Option<crate::ai_serving::CodexResponsesModelCapabilities> {
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
|| !crate::ai_serving::is_openai_responses_family_format(provider_api_format)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
crate::ai_serving::resolve_codex_responses_model_capabilities(
|
||||
provider_model,
|
||||
source_model,
|
||||
transport.key.upstream_metadata.as_ref(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
};
|
||||
use super::{apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers};
|
||||
use crate::ai_serving::planner::standard::build_local_openai_responses_request_body;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
@@ -10,7 +8,7 @@ use serde_json::json;
|
||||
#[test]
|
||||
fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5.4",
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.9,
|
||||
@@ -31,10 +29,11 @@ fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
|
||||
assert!(body.get("top_p").is_none());
|
||||
assert!(body.get("metadata").is_none());
|
||||
assert_eq!(body["store"], false);
|
||||
assert_eq!(body["instructions"], "");
|
||||
assert!(body.get("instructions").is_none());
|
||||
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
|
||||
assert_eq!(body["parallel_tool_calls"], true);
|
||||
assert!(body.get("reasoning").is_none());
|
||||
assert_eq!(body["reasoning"]["effort"], "medium");
|
||||
assert!(body["reasoning"].get("summary").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -80,7 +79,7 @@ fn strips_store_for_compact_even_when_body_rules_handle_it() {
|
||||
{"action":"set","path":"top_p","value":0.5}
|
||||
]);
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5.4",
|
||||
"max_output_tokens": 128,
|
||||
"metadata": {"client": "desktop", "mode": "custom"},
|
||||
"store": true,
|
||||
@@ -99,12 +98,29 @@ fn strips_store_for_compact_even_when_body_rules_handle_it() {
|
||||
assert!(body.get("max_output_tokens").is_none());
|
||||
assert!(body.get("store").is_none());
|
||||
assert_eq!(body["instructions"], "Keep custom");
|
||||
assert_eq!(body["metadata"]["mode"], "custom");
|
||||
assert_eq!(body["top_p"], 0.5);
|
||||
assert!(body.get("metadata").is_none());
|
||||
assert!(body.get("top_p").is_none());
|
||||
assert_eq!(body["parallel_tool_calls"], true);
|
||||
assert!(body.as_object().is_some_and(|object| {
|
||||
object.keys().all(|field| {
|
||||
matches!(
|
||||
field.as_str(),
|
||||
"model"
|
||||
| "input"
|
||||
| "instructions"
|
||||
| "tools"
|
||||
| "parallel_tool_calls"
|
||||
| "reasoning"
|
||||
| "service_tier"
|
||||
| "prompt_cache_key"
|
||||
| "text"
|
||||
)
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_stable_prompt_cache_key_for_codex_requests() {
|
||||
fn does_not_synthesize_prompt_cache_key_from_api_key_identity() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
@@ -118,10 +134,7 @@ fn injects_stable_prompt_cache_key_for_codex_requests() {
|
||||
Some("key-123"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
body["prompt_cache_key"],
|
||||
"53363264-dbb0-5f9d-b9c7-3e92c45c5bdf"
|
||||
);
|
||||
assert!(body.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -144,57 +157,91 @@ fn keeps_existing_prompt_cache_key_for_codex_requests() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_chatgpt_account_id_and_session_headers_for_codex_requests() {
|
||||
fn injects_identity_headers_without_deriving_session_headers_from_body() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("x-client-request-id"), None);
|
||||
assert_eq!(
|
||||
headers.get("user-agent"),
|
||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
assert_eq!(headers.get("session-id"), None);
|
||||
assert_eq!(headers.get("thread-id"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_only_codex_client_headers_for_images_requests() {
|
||||
let mut headers = BTreeMap::new();
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "draw a city"
|
||||
}),
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:image",
|
||||
Some("trace-codex-image-123"),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-client-request-id"),
|
||||
Some(&"trace-codex-123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("user-agent"),
|
||||
Some(
|
||||
&"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(headers.get("originator"), Some(&"codex-tui".to_string()));
|
||||
assert_eq!(
|
||||
headers.get("session_id"),
|
||||
Some(&"ab5ecce4f0d110fe".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("conversation_id"),
|
||||
Some(&"ab5ecce4f0d110fe".to_string())
|
||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
for name in ["x-client-request-id", "session-id", "thread-id"] {
|
||||
assert!(
|
||||
!headers.contains_key(name),
|
||||
"unexpected Images header: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_existing_codex_request_and_session_headers() {
|
||||
fn preserves_client_context_headers_and_enforces_codex_auth_identity_headers() {
|
||||
let mut headers = BTreeMap::new();
|
||||
headers.insert(
|
||||
"x-client-request-id".to_string(),
|
||||
"kept-by-rule-request".to_string(),
|
||||
);
|
||||
headers.insert("session_id".to_string(), "kept-by-rule".to_string());
|
||||
headers.insert("session-id".to_string(), "kept-by-rule-session".to_string());
|
||||
headers.insert("thread-id".to_string(), "kept-by-rule-thread".to_string());
|
||||
headers.insert(
|
||||
"chatgpt-account-id".to_string(),
|
||||
"configured-spoof".to_string(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-openai-fedramp".to_string(),
|
||||
"configured-false".to_string(),
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||
@@ -205,12 +252,12 @@ fn respects_existing_codex_request_and_session_headers() {
|
||||
HeaderValue::from_static("user-specified-request"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"session_id",
|
||||
"session-id",
|
||||
HeaderValue::from_static("user-specified-session"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"conversation_id",
|
||||
HeaderValue::from_static("user-specified-conversation"),
|
||||
"thread-id",
|
||||
HeaderValue::from_static("user-specified-thread"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"user-agent",
|
||||
@@ -220,15 +267,21 @@ fn respects_existing_codex_request_and_session_headers() {
|
||||
"originator",
|
||||
HeaderValue::from_static("user-specified-originator"),
|
||||
);
|
||||
original_headers.insert("version", HeaderValue::from_static("user-version"));
|
||||
original_headers.insert("x-openai-fedramp", HeaderValue::from_static("user-fedramp"));
|
||||
original_headers.insert(
|
||||
"chatgpt-account-id",
|
||||
HeaderValue::from_static("user-account"),
|
||||
);
|
||||
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&original_headers,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -237,47 +290,52 @@ fn respects_existing_codex_request_and_session_headers() {
|
||||
);
|
||||
assert!(!headers.contains_key("user-agent"));
|
||||
assert!(!headers.contains_key("originator"));
|
||||
assert_eq!(headers.get("session_id"), Some(&"kept-by-rule".to_string()));
|
||||
assert!(!headers.contains_key("conversation_id"));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
assert_eq!(
|
||||
headers.get("session-id"),
|
||||
Some(&"kept-by-rule-session".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("thread-id"),
|
||||
Some(&"kept-by-rule-thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_conversation_id_for_compact_codex_requests() {
|
||||
fn compact_does_not_derive_session_headers_from_body() {
|
||||
let mut headers = BTreeMap::new();
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
Some("trace-codex-compact-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("chatgpt-account-id"),
|
||||
Some(&"acc-123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-client-request-id"),
|
||||
Some(&"trace-codex-compact-123".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("x-client-request-id"), None);
|
||||
assert_eq!(
|
||||
headers.get("user-agent"),
|
||||
Some(
|
||||
&"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)"
|
||||
.to_string()
|
||||
)
|
||||
Some(&"codex_cli_rs/0.144.1".to_string())
|
||||
);
|
||||
assert_eq!(headers.get("originator"), Some(&"codex-tui".to_string()));
|
||||
assert_eq!(
|
||||
headers.get("session_id"),
|
||||
Some(&"ab5ecce4f0d110fe".to_string())
|
||||
);
|
||||
assert!(!headers.contains_key("conversation_id"));
|
||||
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
|
||||
assert!(!headers.contains_key("version"));
|
||||
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
|
||||
assert_eq!(headers.get("session-id"), None);
|
||||
assert_eq!(headers.get("thread-id"), None);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ pub(super) async fn resolve_local_standard_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
Some(requested_model.as_str()),
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
None,
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -119,6 +121,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
);
|
||||
let preselection = preselect_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
false,
|
||||
@@ -243,6 +246,7 @@ pub(super) async fn build_local_standard_candidate_attempt_source<'a>(
|
||||
let (source, candidate_count) =
|
||||
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
@@ -338,6 +342,7 @@ async fn maybe_append_gemini_image_openai_image_preselection(
|
||||
|
||||
let image_preselection = preselect_local_execution_candidates_for_api_formats_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
spec_metadata.require_streaming,
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::ai_serving::planner::report_context::{
|
||||
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_serving::planner::CandidateFailureDiagnostic;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
@@ -176,7 +176,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
transport_profile: _,
|
||||
request_redacted: _,
|
||||
} = resolved;
|
||||
let request_gzip = resolve_transport_request_gzip_policy(&transport);
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
@@ -186,6 +186,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.to_string(),
|
||||
provider_name: candidate.provider_name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
@@ -203,8 +204,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
request_gzip,
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts,
|
||||
@@ -213,7 +214,11 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
Ok(Some(decision))
|
||||
}
|
||||
|
||||
@@ -372,6 +377,7 @@ mod tests {
|
||||
routing_policy: None,
|
||||
routing_trace_seed: None,
|
||||
routing_context: None,
|
||||
model_directive_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +481,7 @@ mod tests {
|
||||
} else {
|
||||
"gpt-4o-upstream".to_string()
|
||||
},
|
||||
supports_streaming: true,
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ use crate::ai_serving::planner::redaction::{
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_headers, apply_deepseek_tool_call_thinking_compat,
|
||||
is_deepseek_provider, request_body_build_failure_extra_data,
|
||||
request_conversion_failure_extra_data,
|
||||
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
|
||||
codex_model_capabilities_for_transport, is_deepseek_provider,
|
||||
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
|
||||
};
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
@@ -44,7 +44,9 @@ use crate::ai_serving::transport::{
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
build_openai_image_request_body_from_gemini_image_request, gemini_request_is_image_generation,
|
||||
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
|
||||
CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||
OpenAiImageOperation,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -313,7 +315,13 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
{
|
||||
return Ok(
|
||||
resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
state, parts, trace_id, body_json, input, attempt,
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
attempt,
|
||||
spec_metadata.require_streaming,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
@@ -555,13 +563,27 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
);
|
||||
let force_body_stream_field =
|
||||
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
|
||||
let enable_model_directives =
|
||||
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await;
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
|
||||
let model_directive_mapping = match model_directive_resolution
|
||||
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
|
||||
{
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let redaction = resolve_provider_chat_pii_redaction(
|
||||
state,
|
||||
parts,
|
||||
@@ -588,7 +610,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
Some(effective_headers),
|
||||
enable_model_directives,
|
||||
false,
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
@@ -655,18 +677,8 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
provider_api_format,
|
||||
Some(body_json),
|
||||
);
|
||||
if let Some(mapping) =
|
||||
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(
|
||||
&mut provider_request_body,
|
||||
&mapping,
|
||||
);
|
||||
if let Some(mapping) = model_directive_mapping.as_ref() {
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(&mut provider_request_body, mapping);
|
||||
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
|
||||
// re-enforce stream-field policy afterward.
|
||||
enforce_provider_body_stream_policy(
|
||||
@@ -712,6 +724,64 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_provider_api_format =
|
||||
crate::ai_serving::normalize_api_format_alias(provider_api_format);
|
||||
if matches!(
|
||||
normalized_provider_api_format.as_str(),
|
||||
"openai:chat" | "openai:responses" | "openai:responses:compact"
|
||||
) {
|
||||
let source_model = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(input.requested_model.as_str());
|
||||
let codex_model_capabilities = codex_model_capabilities_for_transport(
|
||||
transport,
|
||||
provider_api_format,
|
||||
prepared_candidate.mapped_model.as_str(),
|
||||
source_model,
|
||||
);
|
||||
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut provider_request_body,
|
||||
crate::ai_serving::OpenAiProviderRequestFinalization {
|
||||
source_api_format: spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
provider_type: transport.provider.provider_type.as_str(),
|
||||
provider_model: prepared_candidate.mapped_model.as_str(),
|
||||
source_model,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
upstream_is_stream,
|
||||
require_body_stream_field: request_requires_body_stream_field(
|
||||
body_json,
|
||||
force_body_stream_field,
|
||||
),
|
||||
},
|
||||
codex_model_capabilities.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
mark_skipped_local_standard_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_conversion_failure_extra_data(
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
Some(parts.uri.path()),
|
||||
upstream_is_stream,
|
||||
"standard_family_request_finalization",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
return Ok(build_kiro_cross_format_payload_parts(
|
||||
state,
|
||||
@@ -752,8 +822,6 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
.await);
|
||||
}
|
||||
|
||||
let normalized_provider_api_format =
|
||||
crate::ai_serving::normalize_api_format_alias(provider_api_format);
|
||||
if normalized_provider_api_format == "gemini:generate_content"
|
||||
&& is_gemini_cli_provider_transport(transport)
|
||||
{
|
||||
@@ -838,7 +906,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
@@ -988,7 +1056,7 @@ async fn build_gemini_cli_cross_format_payload_parts(
|
||||
};
|
||||
|
||||
let mut provider_request_headers = resolved.headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&resolved.body,
|
||||
effective_headers,
|
||||
@@ -1146,6 +1214,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalStandardDecisionInput,
|
||||
attempt: &LocalStandardCandidateAttempt,
|
||||
client_requires_streaming: bool,
|
||||
) -> Option<LocalStandardCandidatePayloadParts> {
|
||||
let client_api_format = "gemini:generate_content";
|
||||
let provider_api_format = "openai:image";
|
||||
@@ -1221,9 +1290,44 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
return None;
|
||||
};
|
||||
|
||||
let upstream_is_stream = true;
|
||||
let upstream_url =
|
||||
build_openai_image_upstream_url(transport, Some("/v1/images/generations"), None);
|
||||
let upstream_is_stream = resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
client_requires_streaming && candidate.supports_streaming,
|
||||
false,
|
||||
);
|
||||
let is_codex = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
let mut provider_request_body = converted.body_json;
|
||||
if upstream_is_stream {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
provider_request_body = project_openai_image_api_request_body(
|
||||
&provider_request_body,
|
||||
&prepared_candidate.mapped_model,
|
||||
converted.operation,
|
||||
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
|
||||
transport.provider.provider_type.as_str(),
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
),
|
||||
)?;
|
||||
if is_codex {
|
||||
provider_request_body = project_codex_openai_image_api_request_body(
|
||||
&provider_request_body,
|
||||
converted.operation,
|
||||
)?;
|
||||
}
|
||||
let request_path = match converted.operation {
|
||||
OpenAiImageOperation::Generate => "/v1/images/generations",
|
||||
OpenAiImageOperation::Edit => "/v1/images/edits",
|
||||
};
|
||||
let upstream_url = build_openai_image_upstream_url(transport, Some(request_path), None);
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let Some(mut provider_request_headers) =
|
||||
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
@@ -1231,9 +1335,15 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
headers: effective_headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
accept: "text/event-stream",
|
||||
accept: if is_codex {
|
||||
None
|
||||
} else if upstream_is_stream {
|
||||
Some("text/event-stream")
|
||||
} else {
|
||||
Some("application/json")
|
||||
},
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &converted.body_json,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
})
|
||||
else {
|
||||
@@ -1254,9 +1364,9 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&converted.body_json,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
@@ -1269,7 +1379,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
|
||||
auth_value: prepared_candidate.auth_value,
|
||||
mapped_model: converted.mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body: converted.body_json,
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
upstream_is_stream,
|
||||
|
||||
@@ -15,7 +15,8 @@ mod normalize;
|
||||
mod openai;
|
||||
|
||||
pub(crate) use self::codex::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
|
||||
codex_model_capabilities_for_transport,
|
||||
};
|
||||
pub(crate) use self::deepseek::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider};
|
||||
pub(crate) use self::family::{
|
||||
@@ -25,9 +26,11 @@ pub(crate) use self::family::{
|
||||
pub(crate) use self::normalize::{
|
||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
|
||||
build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_cross_format_openai_responses_upstream_url, build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_upstream_url, build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_upstream_url,
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_local_openai_responses_upstream_url, validate_final_openai_provider_request,
|
||||
};
|
||||
pub(crate) use self::openai::{
|
||||
build_local_openai_chat_stream_attempt_source_for_kind,
|
||||
@@ -297,7 +300,7 @@ mod tests {
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:messages",
|
||||
"gpt-5",
|
||||
"gpt-5.4",
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"/v1/messages",
|
||||
@@ -309,7 +312,7 @@ mod tests {
|
||||
|
||||
assert!(converted.get("metadata").is_none());
|
||||
assert_eq!(converted["store"], false);
|
||||
assert_eq!(converted["instructions"], "");
|
||||
assert!(converted.get("instructions").is_none());
|
||||
assert_eq!(converted["include"], json!(["reasoning.encrypted_content"]));
|
||||
assert_eq!(converted["parallel_tool_calls"], true);
|
||||
assert_eq!(converted["reasoning"]["effort"], "medium");
|
||||
|
||||
@@ -12,9 +12,38 @@ pub(crate) use self::chat::{
|
||||
};
|
||||
pub(crate) use self::responses::{
|
||||
build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_local_openai_responses_upstream_url,
|
||||
};
|
||||
pub(super) use crate::ai_serving::planner::common::{
|
||||
enforce_provider_body_stream_policy, request_requires_body_stream_field,
|
||||
};
|
||||
|
||||
pub(crate) fn validate_final_openai_provider_request(
|
||||
provider_api_format: &str,
|
||||
mapped_model: &str,
|
||||
source_request_body: &serde_json::Value,
|
||||
provider_request_body: &serde_json::Value,
|
||||
) -> Option<()> {
|
||||
let provider_model = provider_request_body
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(mapped_model);
|
||||
let source_model = source_request_body
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(mapped_model);
|
||||
crate::ai_serving::validate_openai_provider_request_contract(
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
source_model,
|
||||
provider_request_body,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ use crate::ai_serving::{
|
||||
GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{enforce_provider_body_stream_policy, request_requires_body_stream_field};
|
||||
use super::{
|
||||
enforce_provider_body_stream_policy, request_requires_body_stream_field,
|
||||
validate_final_openai_provider_request,
|
||||
};
|
||||
|
||||
pub(crate) fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
@@ -39,6 +42,12 @@ pub(crate) fn build_local_openai_chat_request_body(
|
||||
upstream_is_stream,
|
||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||
);
|
||||
validate_final_openai_provider_request(
|
||||
"openai:chat",
|
||||
mapped_model,
|
||||
body_json,
|
||||
&provider_request_body,
|
||||
)?;
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
@@ -92,6 +101,12 @@ pub(crate) fn build_cross_format_openai_chat_request_body(
|
||||
upstream_is_stream,
|
||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||
);
|
||||
validate_final_openai_provider_request(
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
&provider_request_body,
|
||||
)?;
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@ use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers;
|
||||
use crate::ai_serving::{
|
||||
apply_codex_openai_responses_special_body_edits,
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
build_cross_format_openai_responses_request_body_with_model_directives as surface_build_cross_format_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives as surface_build_local_openai_responses_request_body,
|
||||
GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
use super::{enforce_provider_body_stream_policy, request_requires_body_stream_field};
|
||||
use super::{
|
||||
enforce_provider_body_stream_policy, request_requires_body_stream_field,
|
||||
validate_final_openai_provider_request,
|
||||
};
|
||||
|
||||
pub(crate) fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
@@ -19,9 +21,35 @@ pub(crate) fn build_local_openai_responses_request_body(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
_user_api_key_id: Option<&str>,
|
||||
request_headers: &http::HeaderMap,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json,
|
||||
mapped_model,
|
||||
require_streaming,
|
||||
force_body_stream_field,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
request_headers,
|
||||
None,
|
||||
enable_model_directives,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
force_body_stream_field: bool,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: &http::HeaderMap,
|
||||
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body = surface_build_local_openai_responses_request_body(
|
||||
body_json,
|
||||
@@ -36,12 +64,18 @@ pub(crate) fn build_local_openai_responses_request_body(
|
||||
body_json,
|
||||
request_headers,
|
||||
)?;
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
let source_model = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(mapped_model);
|
||||
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
source_model,
|
||||
model_capabilities,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
@@ -53,6 +87,12 @@ pub(crate) fn build_local_openai_responses_request_body(
|
||||
require_streaming,
|
||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||
);
|
||||
validate_final_openai_provider_request(
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
&provider_request_body,
|
||||
)?;
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
@@ -65,9 +105,37 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
|
||||
force_body_stream_field: bool,
|
||||
provider_type: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
_user_api_key_id: Option<&str>,
|
||||
request_headers: &http::HeaderMap,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
provider_type,
|
||||
body_rules,
|
||||
request_headers,
|
||||
None,
|
||||
enable_model_directives,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
force_body_stream_field: bool,
|
||||
provider_type: &str,
|
||||
body_rules: Option<&Value>,
|
||||
request_headers: &http::HeaderMap,
|
||||
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body = surface_build_cross_format_openai_responses_request_body(
|
||||
body_json,
|
||||
@@ -84,12 +152,18 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
|
||||
body_json,
|
||||
request_headers,
|
||||
)?;
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
let source_model = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(mapped_model);
|
||||
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
source_model,
|
||||
model_capabilities,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
@@ -101,6 +175,12 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
|
||||
upstream_is_stream,
|
||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||
);
|
||||
validate_final_openai_provider_request(
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
&provider_request_body,
|
||||
)?;
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ use http::Request;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_upstream_url,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_responses_request_body, build_local_openai_responses_upstream_url,
|
||||
};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
@@ -146,12 +146,10 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
assert_eq!(provider_request_body["parallel_tool_calls"], json!(true));
|
||||
assert_eq!(provider_request_body["instructions"], json!(""));
|
||||
assert!(provider_request_body.get("instructions").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,18 +179,49 @@ fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_compact_wrapper_strips_include_for_codex_requests() {
|
||||
fn local_codex_compact_wrapper_applies_the_complete_request_projection() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [],
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}],
|
||||
"instructions": "Work carefully",
|
||||
"client_metadata": {"origin": "codex"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"store": true,
|
||||
"stream": true
|
||||
"stream": true,
|
||||
"stream_options": {"reasoning_summary_delivery": "sequential_cutoff"},
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "max", "context": "all_turns"},
|
||||
"text": {"verbosity": "medium"},
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}],
|
||||
"service_tier": "priority",
|
||||
"prompt_cache_key": "thread-compact"
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_responses_request_body(
|
||||
let regular = build_local_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
"gpt-5.6-sol",
|
||||
true,
|
||||
false,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.expect("local Codex Responses body should build");
|
||||
let compact = build_local_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.6-sol",
|
||||
false,
|
||||
false,
|
||||
"codex",
|
||||
@@ -202,22 +231,44 @@ fn local_openai_responses_compact_wrapper_strips_include_for_codex_requests() {
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.expect("local codex compact body should build");
|
||||
.expect("local Codex Compact body should build");
|
||||
|
||||
assert!(provider_request_body.get("include").is_none());
|
||||
assert!(provider_request_body.get("store").is_none());
|
||||
assert!(provider_request_body.get("stream").is_none());
|
||||
assert_eq!(provider_request_body["instructions"], "");
|
||||
assert_eq!(
|
||||
provider_request_body["prompt_cache_key"],
|
||||
"3d2e2842-74cb-55dd-803a-b8940b3500c2"
|
||||
);
|
||||
for field in [
|
||||
"client_metadata",
|
||||
"include",
|
||||
"store",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"tool_choice",
|
||||
] {
|
||||
assert!(
|
||||
regular.get(field).is_some(),
|
||||
"Responses should contain {field}"
|
||||
);
|
||||
assert!(compact.get(field).is_none(), "Compact should omit {field}");
|
||||
}
|
||||
for field in [
|
||||
"model",
|
||||
"input",
|
||||
"instructions",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"text",
|
||||
"tools",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
] {
|
||||
assert_eq!(
|
||||
compact[field], regular[field],
|
||||
"Compact should preserve {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-max",
|
||||
"model": "gpt-5.6-sol-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
@@ -227,7 +278,7 @@ fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
|
||||
|
||||
let provider_request_body = build_local_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
"gpt-5.6-sol",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
@@ -244,6 +295,132 @@ fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
|
||||
assert_eq!(provider_request_body["metadata"]["override_seen"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() {
|
||||
let alias = json!({
|
||||
"model": "deployment-alias",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max"}
|
||||
});
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&alias,
|
||||
"gpt-5.6-sol",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_some());
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&alias,
|
||||
"gpt-5.4",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let minimal = json!({
|
||||
"model": "deployment-alias",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "minimal"
|
||||
});
|
||||
assert!(build_local_openai_chat_request_body(
|
||||
&minimal,
|
||||
"gpt-5.6-terra",
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let opaque_mapping = json!({
|
||||
"model": "gpt-5.6-sol-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max", "mode": "pro"},
|
||||
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
|
||||
});
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&opaque_mapping,
|
||||
"azure-production",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_some());
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&opaque_mapping,
|
||||
"gpt-5.4",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_openai_provider_contract_validates_body_rule_output() {
|
||||
let body = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "max"}
|
||||
});
|
||||
let model_override = json!([
|
||||
{"action":"set","path":"model","value":"gpt-5.4"}
|
||||
]);
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&body,
|
||||
"gpt-5.6-sol",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
Some(&model_override),
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let cache_override = json!([
|
||||
{"action":"set","path":"prompt_cache_options.ttl","value":"1h"}
|
||||
]);
|
||||
assert!(build_local_openai_responses_request_body(
|
||||
&json!({"model":"gpt-5.6-sol","input":"hello"}),
|
||||
"gpt-5.6-sol",
|
||||
false,
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses",
|
||||
Some(&cache_override),
|
||||
None,
|
||||
&http::HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_upstream_url_preserves_codex_base_path() {
|
||||
let request = Request::builder()
|
||||
@@ -371,7 +548,7 @@ fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
|
||||
fn omits_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
|
||||
let body_json = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [{
|
||||
@@ -395,14 +572,11 @@ fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
|
||||
)
|
||||
.expect("claude cli to codex request should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["prompt_cache_key"],
|
||||
"4ee6ea6e-3ac6-5a18-8cb8-1f8b956419e5"
|
||||
);
|
||||
assert!(provider_request_body.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
|
||||
fn omits_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
@@ -425,8 +599,5 @@ fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
|
||||
)
|
||||
.expect("openai chat to codex request should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["prompt_cache_key"],
|
||||
"4ee6ea6e-3ac6-5a18-8cb8-1f8b956419e5"
|
||||
);
|
||||
assert!(provider_request_body.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
+19
-5
@@ -6,7 +6,7 @@ use crate::ai_serving::planner::report_context::{
|
||||
insert_provider_stream_event_api_format, LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
@@ -44,6 +44,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let upstream_is_stream = upstream_is_stream && eligible.candidate.supports_streaming;
|
||||
let payload_started_at = std::time::Instant::now();
|
||||
let Some(resolved) = resolve_local_openai_chat_candidate_payload_parts(
|
||||
state,
|
||||
@@ -72,6 +73,14 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
payload_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
let candidate = &eligible.candidate;
|
||||
let upstream_is_stream =
|
||||
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
||||
resolved.transport.endpoint.config.as_ref(),
|
||||
resolved.transport.provider.provider_type.as_str(),
|
||||
resolved.provider_api_format.as_str(),
|
||||
upstream_is_stream,
|
||||
false,
|
||||
);
|
||||
|
||||
let prompt_cache_key = resolved
|
||||
.provider_request_body
|
||||
@@ -208,7 +217,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
"stream_candidate_report_context",
|
||||
report_context_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
let request_gzip = resolve_transport_request_gzip_policy(&transport);
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let decision_started_at = std::time::Instant::now();
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
@@ -219,6 +228,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
@@ -236,8 +246,8 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
request_gzip,
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts,
|
||||
@@ -246,7 +256,11 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
observe_gateway_stage_ms(
|
||||
"stream_candidate_decision_build",
|
||||
decision_started_at.elapsed().as_millis() as u64,
|
||||
|
||||
+578
-143
@@ -25,11 +25,11 @@ use crate::ai_serving::planner::redaction::{
|
||||
request_identity_response_encoding_when_redacted, resolve_provider_chat_pii_redaction,
|
||||
};
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
|
||||
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_upstream_url, request_body_build_failure_extra_data,
|
||||
request_conversion_failure_extra_data,
|
||||
build_local_openai_chat_upstream_url, codex_model_capabilities_for_transport,
|
||||
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
|
||||
};
|
||||
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
|
||||
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
|
||||
@@ -56,7 +56,10 @@ use crate::ai_serving::transport::{
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
LocalResolvedOAuthRequestAuth,
|
||||
LocalResolvedOAuthRequestAuth, OpenAiImageOperation,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
use crate::stage_metrics::observe_gateway_stage_ms;
|
||||
@@ -88,37 +91,7 @@ pub(crate) struct LocalOpenAiChatCandidatePayloadParts {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LocalOpenAiChatRequestPreparation {
|
||||
model_directives_enabled: BTreeMap<(String, String), bool>,
|
||||
}
|
||||
|
||||
impl LocalOpenAiChatRequestPreparation {
|
||||
async fn model_directives_enabled(
|
||||
&mut self,
|
||||
state: &AppState,
|
||||
provider_api_format: &str,
|
||||
requested_model: &str,
|
||||
) -> bool {
|
||||
let key = (
|
||||
provider_api_format.trim().to_ascii_lowercase(),
|
||||
requested_model.trim().to_string(),
|
||||
);
|
||||
if let Some(enabled) = self.model_directives_enabled.get(&key) {
|
||||
crate::stage_metrics::record_openai_chat_model_directive_cache_hit();
|
||||
return *enabled;
|
||||
}
|
||||
crate::stage_metrics::record_openai_chat_model_directive_cache_miss();
|
||||
let enabled =
|
||||
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(requested_model),
|
||||
)
|
||||
.await;
|
||||
self.model_directives_enabled.insert(key, enabled);
|
||||
enabled
|
||||
}
|
||||
}
|
||||
pub(crate) struct LocalOpenAiChatRequestPreparation;
|
||||
|
||||
fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
|
||||
matches!(
|
||||
@@ -127,6 +100,65 @@ fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn finalize_openai_chat_provider_request_body(
|
||||
provider_request_body: &mut Value,
|
||||
custom_directive_mapping: Option<&Value>,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
force_body_stream_field: bool,
|
||||
original_body: &Value,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
) -> bool {
|
||||
if let Some(mapping) = custom_directive_mapping {
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(provider_request_body, mapping);
|
||||
}
|
||||
|
||||
// Mapping and endpoint body rules can both write `stream`. The resolved transport
|
||||
// policy is authoritative and therefore runs after every body mutation.
|
||||
enforce_provider_body_stream_policy(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
request_requires_body_stream_field(original_body, force_body_stream_field),
|
||||
);
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.endpoint.base_url.as_str(),
|
||||
provider_api_format,
|
||||
Some(original_body),
|
||||
);
|
||||
let source_model = original_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(mapped_model);
|
||||
let codex_model_capabilities = codex_model_capabilities_for_transport(
|
||||
transport,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
source_model,
|
||||
);
|
||||
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
provider_request_body,
|
||||
crate::ai_serving::OpenAiProviderRequestFinalization {
|
||||
source_api_format: "openai:chat",
|
||||
provider_api_format,
|
||||
provider_type: transport.provider.provider_type.as_str(),
|
||||
provider_model: mapped_model,
|
||||
source_model,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
upstream_is_stream,
|
||||
require_body_stream_field: request_requires_body_stream_field(
|
||||
original_body,
|
||||
force_body_stream_field,
|
||||
),
|
||||
},
|
||||
codex_model_capabilities.as_ref(),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
@@ -134,7 +166,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
mut preparation: Option<&mut LocalOpenAiChatRequestPreparation>,
|
||||
_preparation: Option<&mut LocalOpenAiChatRequestPreparation>,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
@@ -151,18 +183,9 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
let force_body_stream_field =
|
||||
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
|
||||
let model_directives_started_at = std::time::Instant::now();
|
||||
let enable_model_directives = if let Some(preparation) = preparation {
|
||||
preparation
|
||||
.model_directives_enabled(state, provider_api_format, &input.requested_model)
|
||||
.await
|
||||
} else {
|
||||
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
|
||||
observe_gateway_stage_ms(
|
||||
"openai_chat_payload_model_directives",
|
||||
model_directives_started_at.elapsed().as_millis() as u64,
|
||||
@@ -220,15 +243,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let model_directive_mapping = match model_directive_resolution
|
||||
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
|
||||
{
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_request_body) = build_local_openai_chat_request_body(
|
||||
let Some(mut provider_request_body) = build_local_openai_chat_request_body(
|
||||
body_json,
|
||||
&prepared_candidate.mapped_model,
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
false,
|
||||
) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
@@ -247,6 +288,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
if !finalize_openai_chat_provider_request_body(
|
||||
&mut provider_request_body,
|
||||
model_directive_mapping.as_ref(),
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
body_json,
|
||||
transport,
|
||||
&prepared_candidate.mapped_model,
|
||||
) {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_body_build_failure_extra_data(
|
||||
body_json,
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let upstream_url = build_grok_upstream_url(transport, GROK_CHAT_PATH);
|
||||
let Some(mut provider_request_headers) = build_grok_browser_headers(GrokHeaderInput {
|
||||
@@ -379,6 +447,24 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let model_directive_mapping = match model_directive_resolution
|
||||
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
|
||||
{
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
observe_gateway_stage_ms(
|
||||
"openai_chat_payload_auth_prepare",
|
||||
auth_prepare_started_at.elapsed().as_millis() as u64,
|
||||
@@ -392,7 +478,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
force_body_stream_field,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
false,
|
||||
) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
@@ -415,13 +501,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
"openai_chat_payload_body_build",
|
||||
body_build_started_at.elapsed().as_millis() as u64,
|
||||
);
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
if !finalize_openai_chat_provider_request_body(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.endpoint.base_url.as_str(),
|
||||
model_directive_mapping.as_ref(),
|
||||
"openai:chat",
|
||||
Some(body_json),
|
||||
);
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
body_json,
|
||||
transport,
|
||||
&prepared_candidate.mapped_model,
|
||||
) {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_body_build_failure_extra_data(
|
||||
body_json,
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(upstream_url) = build_local_openai_chat_upstream_url(parts, transport) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
@@ -475,7 +581,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
@@ -651,6 +757,24 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
}
|
||||
}
|
||||
};
|
||||
let model_directive_mapping = match model_directive_resolution
|
||||
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
|
||||
{
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let Some(mut provider_request_body) = build_cross_format_openai_chat_request_body(
|
||||
body_json,
|
||||
@@ -666,7 +790,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
false,
|
||||
) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
@@ -689,34 +813,37 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(mapping) =
|
||||
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format.as_str(),
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(
|
||||
&mut provider_request_body,
|
||||
&mapping,
|
||||
);
|
||||
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
|
||||
// re-enforce stream-field policy afterward.
|
||||
enforce_provider_body_stream_policy(
|
||||
&mut provider_request_body,
|
||||
provider_api_format.as_str(),
|
||||
upstream_is_stream,
|
||||
request_requires_body_stream_field(body_json, force_body_stream_field),
|
||||
);
|
||||
}
|
||||
apply_deepseek_tool_call_thinking_compat(
|
||||
if !finalize_openai_chat_provider_request_body(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.endpoint.base_url.as_str(),
|
||||
model_directive_mapping.as_ref(),
|
||||
provider_api_format.as_str(),
|
||||
Some(body_json),
|
||||
);
|
||||
upstream_is_stream,
|
||||
force_body_stream_field,
|
||||
body_json,
|
||||
transport,
|
||||
&prepared_candidate.mapped_model,
|
||||
) {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_conversion_failure_extra_data(
|
||||
body_json,
|
||||
"openai:chat",
|
||||
provider_api_format.as_str(),
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
Some(parts.uri.path()),
|
||||
upstream_is_stream,
|
||||
"openai_chat_request_conversion",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
return Ok(build_kiro_openai_chat_cross_format_payload_parts(
|
||||
@@ -845,7 +972,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
@@ -997,7 +1124,7 @@ async fn build_antigravity_openai_chat_cross_format_payload_parts(
|
||||
}
|
||||
};
|
||||
let mut provider_request_headers = resolved.headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&resolved.body,
|
||||
effective_headers,
|
||||
@@ -1148,7 +1275,7 @@ async fn build_gemini_cli_openai_chat_cross_format_payload_parts(
|
||||
}
|
||||
};
|
||||
let mut provider_request_headers = resolved.headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&resolved.body,
|
||||
effective_headers,
|
||||
@@ -1255,6 +1382,19 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web");
|
||||
let is_codex = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
let upstream_is_stream =
|
||||
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
);
|
||||
let Some((mut provider_request_body, image_request_summary)) = (if is_chatgpt_web {
|
||||
build_chatgpt_web_image_provider_body_from_openai_chat_body(
|
||||
body_json,
|
||||
@@ -1263,7 +1403,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||
} else {
|
||||
build_openai_image_provider_body_from_openai_chat_body(
|
||||
body_json,
|
||||
&input.requested_model,
|
||||
&prepared_candidate.mapped_model,
|
||||
upstream_is_stream,
|
||||
)
|
||||
}) else {
|
||||
@@ -1280,24 +1420,70 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(operation) = openai_image_operation_from_summary(&image_request_summary) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_chatgpt_web {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
Some(candidate.key_id.as_str()),
|
||||
);
|
||||
let Some(projected) = project_openai_image_api_request_body(
|
||||
&provider_request_body,
|
||||
&prepared_candidate.mapped_model,
|
||||
operation,
|
||||
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
|
||||
transport.provider.provider_type.as_str(),
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
),
|
||||
) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_body_build_failure_extra_data(
|
||||
body_json,
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
provider_request_body = projected;
|
||||
}
|
||||
if is_codex {
|
||||
let Some(projected) =
|
||||
project_codex_openai_image_api_request_body(&provider_request_body, operation)
|
||||
else {
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_body_build_failure_extra_data(
|
||||
body_json,
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
provider_request_body = projected;
|
||||
}
|
||||
|
||||
let upstream_url = if is_chatgpt_web {
|
||||
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
|
||||
} else {
|
||||
build_openai_image_upstream_url(
|
||||
transport,
|
||||
Some("/v1/images/generations"),
|
||||
parts.uri.query(),
|
||||
)
|
||||
let request_path = match operation {
|
||||
OpenAiImageOperation::Generate => "/v1/images/generations",
|
||||
OpenAiImageOperation::Edit => "/v1/images/edits",
|
||||
};
|
||||
build_openai_image_upstream_url(transport, Some(request_path), parts.uri.query())
|
||||
};
|
||||
let Some(mut provider_request_headers) =
|
||||
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
@@ -1305,7 +1491,13 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||
headers: &parts.headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
accept: "text/event-stream",
|
||||
accept: if is_codex {
|
||||
None
|
||||
} else if upstream_is_stream {
|
||||
Some("text/event-stream")
|
||||
} else {
|
||||
Some("application/json")
|
||||
},
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
@@ -1331,7 +1523,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
|
||||
if is_chatgpt_web {
|
||||
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
|
||||
} else {
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
@@ -1386,46 +1578,24 @@ fn build_openai_image_provider_body_from_openai_chat_body(
|
||||
copy_openai_chat_image_option(body_json, &mut image_options, "input_fidelity");
|
||||
copy_openai_chat_image_option(body_json, &mut image_options, "partial_images");
|
||||
|
||||
let input = if images.is_empty() {
|
||||
serde_json::json!([{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
}])
|
||||
} else {
|
||||
let mut content = vec![serde_json::json!({
|
||||
"type": "input_text",
|
||||
"text": prompt,
|
||||
})];
|
||||
content.extend(images);
|
||||
serde_json::json!([{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}])
|
||||
};
|
||||
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(model) = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
let requested_model = requested_model.trim();
|
||||
(!requested_model.is_empty()).then_some(requested_model)
|
||||
})
|
||||
{
|
||||
body.insert("model".to_string(), Value::String(model.to_string()));
|
||||
let requested_model = requested_model.trim();
|
||||
if requested_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
body.insert("input".to_string(), input);
|
||||
let mut image_tool = image_options.clone();
|
||||
image_tool.insert(
|
||||
"type".to_string(),
|
||||
Value::String("image_generation".to_string()),
|
||||
);
|
||||
body.insert(
|
||||
"tools".to_string(),
|
||||
Value::Array(vec![Value::Object(image_tool)]),
|
||||
"model".to_string(),
|
||||
Value::String(requested_model.to_string()),
|
||||
);
|
||||
body.insert("prompt".to_string(), Value::String(prompt));
|
||||
body.extend(image_options.clone());
|
||||
if operation == "edit" {
|
||||
let image_urls = openai_image_inputs_as_api_urls(&images);
|
||||
if image_urls.len() != images.len() {
|
||||
return None;
|
||||
}
|
||||
body.insert("images".to_string(), Value::Array(image_urls));
|
||||
}
|
||||
if upstream_is_stream {
|
||||
body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
@@ -1451,6 +1621,14 @@ fn build_openai_image_provider_body_from_openai_chat_body(
|
||||
Some((Value::Object(body), Value::Object(summary)))
|
||||
}
|
||||
|
||||
fn openai_image_operation_from_summary(summary: &Value) -> Option<OpenAiImageOperation> {
|
||||
match summary.get("operation")?.as_str()? {
|
||||
"generate" => Some(OpenAiImageOperation::Generate),
|
||||
"edit" => Some(OpenAiImageOperation::Edit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chatgpt_web_image_provider_body_from_openai_chat_body(
|
||||
body_json: &Value,
|
||||
requested_model: &str,
|
||||
@@ -1603,6 +1781,20 @@ fn openai_image_inputs_as_urls(images: &[Value]) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn openai_image_inputs_as_api_urls(images: &[Value]) -> Vec<Value> {
|
||||
images
|
||||
.iter()
|
||||
.filter_map(|image| {
|
||||
image
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| json!({ "image_url": value }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn chatgpt_web_ratio_for_size(size: &str) -> String {
|
||||
let Some((width, height)) = size.split_once('x') else {
|
||||
return "1:1".to_string();
|
||||
@@ -2011,6 +2203,7 @@ mod tests {
|
||||
routing_policy: None,
|
||||
routing_trace_seed: None,
|
||||
routing_context: None,
|
||||
model_directive_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2096,6 +2289,7 @@ mod tests {
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "gemini-2.5-pro".to_string(),
|
||||
selected_provider_model_name: "gemini-2.5-pro".to_string(),
|
||||
supports_streaming: true,
|
||||
mapping_matched_model: None,
|
||||
},
|
||||
transport: Arc::new(sample_gemini_cli_transport()),
|
||||
@@ -2138,6 +2332,238 @@ mod tests {
|
||||
eligible
|
||||
}
|
||||
|
||||
fn sample_openai_chat_eligible(provider_type: &str) -> EligibleLocalExecutionCandidate {
|
||||
let mut transport = sample_gemini_cli_transport();
|
||||
transport.provider.name = provider_type.to_string();
|
||||
transport.provider.provider_type = provider_type.to_string();
|
||||
transport.endpoint.api_format = "openai:chat".to_string();
|
||||
transport.endpoint.api_family = Some("openai".to_string());
|
||||
transport.endpoint.endpoint_kind = Some("chat_completions".to_string());
|
||||
transport.endpoint.base_url = if provider_type == "grok" {
|
||||
"https://grok.com".to_string()
|
||||
} else {
|
||||
"https://api.openai.test".to_string()
|
||||
};
|
||||
transport.endpoint.custom_path = None;
|
||||
transport.key.api_formats = Some(vec!["openai:chat".to_string()]);
|
||||
transport.key.upstream_metadata = None;
|
||||
if provider_type == "grok" {
|
||||
transport.key.auth_type = "oauth".to_string();
|
||||
transport.key.decrypted_api_key.clear();
|
||||
transport.key.decrypted_auth_config =
|
||||
Some(json!({ "sso_token": "test-session" }).to_string());
|
||||
} else {
|
||||
transport.key.auth_type = "bearer".to_string();
|
||||
transport.key.decrypted_api_key = "test-api-key".to_string();
|
||||
transport.key.decrypted_auth_config = None;
|
||||
}
|
||||
|
||||
let mut eligible = sample_gemini_cli_eligible();
|
||||
eligible.candidate.provider_name = provider_type.to_string();
|
||||
eligible.candidate.provider_type = provider_type.to_string();
|
||||
eligible.candidate.endpoint_api_format = "openai:chat".to_string();
|
||||
eligible.candidate.global_model_name = "gpt-5.6-sol".to_string();
|
||||
eligible.candidate.selected_provider_model_name = "gpt-5.6-sol".to_string();
|
||||
eligible.transport = Arc::new(transport);
|
||||
eligible.provider_api_format = "openai:chat".to_string();
|
||||
eligible
|
||||
}
|
||||
|
||||
fn sample_custom_directive_input() -> LocalOpenAiChatDecisionInput {
|
||||
let mut input = sample_input();
|
||||
input.requested_model = "gpt-5.6-sol-high".to_string();
|
||||
input.model_directive_policy =
|
||||
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
|
||||
Some(&json!(true)),
|
||||
Some(&json!({
|
||||
"reasoning_effort": {
|
||||
"api_formats": {
|
||||
"openai:chat": {
|
||||
"suffixes": ["high"],
|
||||
"mappings": {
|
||||
"high": {
|
||||
"reasoning_effort": "low",
|
||||
"stream": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
);
|
||||
input
|
||||
}
|
||||
|
||||
fn sample_alias_max_directive_input() -> LocalOpenAiChatDecisionInput {
|
||||
let mut input = sample_input();
|
||||
input.requested_model = "deployment-alias-max".to_string();
|
||||
input.model_directive_policy =
|
||||
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
|
||||
Some(&json!(true)),
|
||||
None,
|
||||
);
|
||||
input
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alias_reasoning_directive_is_constrained_by_the_mapped_openai_model() {
|
||||
let state = AppState::new().expect("state should build");
|
||||
let request = http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body_json = json!({
|
||||
"model": "deployment-alias-max",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
let mut supported = sample_openai_chat_eligible("custom");
|
||||
supported.candidate.selected_provider_model_name = "gpt-5.6-sol".to_string();
|
||||
let payload = resolve_local_openai_chat_candidate_payload_parts(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-alias-max-gpt-5.6-sol",
|
||||
&body_json,
|
||||
&sample_alias_max_directive_input(),
|
||||
None,
|
||||
&supported,
|
||||
0,
|
||||
"candidate-0",
|
||||
"openai_chat_sync",
|
||||
"openai_chat_sync_success",
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("candidate resolution should not fail")
|
||||
.expect("GPT-5.6 candidate should build a payload");
|
||||
assert_eq!(payload.provider_request_body["reasoning_effort"], "max");
|
||||
|
||||
let mut unsupported = sample_openai_chat_eligible("custom");
|
||||
unsupported.candidate.selected_provider_model_name = "gpt-5.4".to_string();
|
||||
let payload = resolve_local_openai_chat_candidate_payload_parts(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-alias-max-gpt-5.4",
|
||||
&body_json,
|
||||
&sample_alias_max_directive_input(),
|
||||
None,
|
||||
&unsupported,
|
||||
0,
|
||||
"candidate-0",
|
||||
"openai_chat_sync",
|
||||
"openai_chat_sync_success",
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("candidate resolution should not fail");
|
||||
assert!(payload.is_none(), "GPT-5.4 must reject the max directive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_policy_suffix_patch_is_applied_after_candidate_mapping() {
|
||||
let state = AppState::new().expect("state should build");
|
||||
let request = http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body_json = json!({
|
||||
"model": "deployment-alias-VendorFuture",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
let mut input = sample_input();
|
||||
input.requested_model = "deployment-alias-VendorFuture".to_string();
|
||||
input.model_directive_policy =
|
||||
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
|
||||
Some(&json!(true)),
|
||||
Some(&json!({
|
||||
"reasoning_effort": {
|
||||
"api_formats": {
|
||||
"openai:chat": {
|
||||
"suffixes": ["VendorFuture"],
|
||||
"mappings": {
|
||||
"VendorFuture": {
|
||||
"reasoning_effort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
);
|
||||
let payload = resolve_local_openai_chat_candidate_payload_parts(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-custom-policy-suffix",
|
||||
&body_json,
|
||||
&input,
|
||||
None,
|
||||
&sample_openai_chat_eligible("custom"),
|
||||
0,
|
||||
"candidate-0",
|
||||
"openai_chat_sync",
|
||||
"openai_chat_sync_success",
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("candidate resolution should not fail")
|
||||
.expect("custom directive candidate should build a payload");
|
||||
|
||||
assert_eq!(payload.provider_request_body["model"], "gpt-5.6-sol");
|
||||
assert_eq!(payload.provider_request_body["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_format_and_grok_chat_apply_the_same_custom_directive_finalization() {
|
||||
let state = AppState::new().expect("state should build");
|
||||
let request = http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.6-sol-high",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
for provider_type in ["custom", "grok"] {
|
||||
let payload = resolve_local_openai_chat_candidate_payload_parts(
|
||||
&state,
|
||||
&parts,
|
||||
&format!("trace-directive-{provider_type}"),
|
||||
&body_json,
|
||||
&sample_custom_directive_input(),
|
||||
None,
|
||||
&sample_openai_chat_eligible(provider_type),
|
||||
0,
|
||||
"candidate-0",
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"openai_chat_stream_success",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("candidate resolution should not fail")
|
||||
.expect("same-format candidate should build a payload");
|
||||
|
||||
assert_eq!(
|
||||
payload.provider_request_body["reasoning_effort"], "low",
|
||||
"custom mapping must be authoritative for {provider_type}"
|
||||
);
|
||||
assert_eq!(
|
||||
payload.provider_request_body["stream"], true,
|
||||
"stream policy must be re-applied after mapping for {provider_type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_chat_to_gemini_cli_wraps_cross_format_body_in_v1internal_envelope() {
|
||||
let state = AppState::new().expect("state should build");
|
||||
@@ -2333,7 +2759,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_image_bridge_body_injects_image_generation_tool() {
|
||||
fn openai_chat_image_bridge_builds_images_api_body() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-image-2",
|
||||
"messages": [
|
||||
@@ -2347,13 +2773,22 @@ mod tests {
|
||||
build_openai_image_provider_body_from_openai_chat_body(&body_json, "gpt-image-2", true)
|
||||
.expect("chat image body should convert");
|
||||
|
||||
assert_eq!(provider_body["tools"][0]["type"], "image_generation");
|
||||
assert_eq!(provider_body["tools"][0]["size"], "1024x1024");
|
||||
assert_eq!(provider_body["tools"][0]["output_format"], "png");
|
||||
assert_eq!(provider_body["model"], "gpt-image-2");
|
||||
assert_eq!(provider_body["prompt"], "Draw a glass city");
|
||||
assert_eq!(provider_body["size"], "1024x1024");
|
||||
assert_eq!(provider_body["output_format"], "png");
|
||||
assert_eq!(provider_body["stream"], true);
|
||||
assert_eq!(provider_body["input"][0]["content"], "Draw a glass city");
|
||||
assert!(provider_body.get("tools").is_none());
|
||||
assert!(provider_body.get("input").is_none());
|
||||
assert_eq!(summary["operation"], "generate");
|
||||
assert_eq!(summary["output_format"], "png");
|
||||
|
||||
let (sync_provider_body, _) = build_openai_image_provider_body_from_openai_chat_body(
|
||||
&body_json,
|
||||
"gpt-image-2",
|
||||
false,
|
||||
)
|
||||
.expect("chat image body should convert for a sync upstream");
|
||||
assert!(sync_provider_body.get("stream").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ pub(crate) async fn build_lazy_local_openai_chat_candidate_attempt_source<'a>(
|
||||
);
|
||||
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
trace_id,
|
||||
"openai:chat",
|
||||
&input.requested_model,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(crate) async fn list_local_openai_chat_candidates(
|
||||
> {
|
||||
let outcome = preselect_local_execution_candidates_with_serving(
|
||||
PlannerAppState::new(state),
|
||||
&input.model_directive_policy,
|
||||
"openai:chat",
|
||||
&input.requested_model,
|
||||
require_streaming,
|
||||
|
||||
@@ -65,7 +65,9 @@ pub(crate) async fn resolve_local_openai_chat_decision_input(
|
||||
state,
|
||||
auth_context.clone(),
|
||||
Some(requested_model.as_str()),
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
None,
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -111,7 +111,7 @@ pub(crate) async fn build_local_openai_chat_stream_attempt_source<'a>(
|
||||
input,
|
||||
candidates,
|
||||
prefetched_attempts: VecDeque::new(),
|
||||
request_preparation: LocalOpenAiChatRequestPreparation::default(),
|
||||
request_preparation: LocalOpenAiChatRequestPreparation,
|
||||
},
|
||||
candidate_count,
|
||||
)))
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
stream: effective_upstream_is_stream,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -229,7 +229,7 @@ pub(crate) fn build_openai_responses_stream_plan_from_decision(
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
stream: effective_upstream_is_stream,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -291,6 +291,7 @@ mod tests {
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_type: Some("codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
@@ -385,6 +386,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compact_stream_plan_preserves_non_stream_upstream_mode() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses/compact")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let mut payload = sample_responses_payload();
|
||||
payload.decision_kind = Some("openai_responses_compact_stream".to_string());
|
||||
payload.upstream_url = Some("https://example.com/v1/responses/compact".to_string());
|
||||
payload.provider_api_format = Some("openai:responses:compact".to_string());
|
||||
payload.client_api_format = Some("openai:responses:compact".to_string());
|
||||
payload.upstream_is_stream = false;
|
||||
payload.provider_request_body = Some(json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [],
|
||||
"instructions": "You are Codex.",
|
||||
"tools": [],
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "high"},
|
||||
"prompt_cache_key": "cache-key",
|
||||
"text": {"verbosity": "low"}
|
||||
}));
|
||||
|
||||
let built =
|
||||
build_openai_responses_stream_plan_from_decision(&parts, &json!({}), payload, true)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert!(!built.plan.stream);
|
||||
assert!(built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.is_some_and(|body| body.get("stream").is_none()));
|
||||
assert!(built.plan.headers.get("accept").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
@@ -404,6 +445,7 @@ mod tests {
|
||||
request_id: Some("req_stream_456".to_string()),
|
||||
candidate_id: Some("cand_stream_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_type: Some("openai".to_string()),
|
||||
provider_id: Some("prov_stream_456".to_string()),
|
||||
endpoint_id: Some("ep_stream_456".to_string()),
|
||||
key_id: Some("key_stream_456".to_string()),
|
||||
@@ -462,7 +504,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_keeps_downstream_stream_for_force_non_stream_upstream() {
|
||||
fn build_openai_chat_stream_plan_preserves_force_non_stream_upstream_mode() {
|
||||
fn force_non_stream_payload(provider_request_body: Option<Value>) -> AiExecutionDecision {
|
||||
AiExecutionDecision {
|
||||
action: "stream".to_string(),
|
||||
@@ -472,6 +514,7 @@ mod tests {
|
||||
request_id: Some("req_force_non_stream".to_string()),
|
||||
candidate_id: Some("cand_force_non_stream".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_type: Some("openai".to_string()),
|
||||
provider_id: Some("prov_force_non_stream".to_string()),
|
||||
endpoint_id: Some("ep_force_non_stream".to_string()),
|
||||
key_id: Some("key_force_non_stream".to_string()),
|
||||
@@ -523,7 +566,7 @@ mod tests {
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert!(built.plan.stream);
|
||||
assert!(!built.plan.stream);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
@@ -548,7 +591,7 @@ mod tests {
|
||||
.expect("fallback plan build should succeed")
|
||||
.expect("fallback plan should be produced");
|
||||
|
||||
assert!(built.plan.stream);
|
||||
assert!(!built.plan.stream);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
@@ -579,6 +622,7 @@ mod tests {
|
||||
request_id: Some("req_stream_789".to_string()),
|
||||
candidate_id: Some("cand_stream_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_type: Some("anthropic".to_string()),
|
||||
provider_id: Some("prov_stream_789".to_string()),
|
||||
endpoint_id: Some("ep_stream_789".to_string()),
|
||||
key_id: Some("key_stream_789".to_string()),
|
||||
|
||||
@@ -257,6 +257,7 @@ mod tests {
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_type: Some("codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
@@ -369,6 +370,7 @@ mod tests {
|
||||
request_id: Some("req_456".to_string()),
|
||||
candidate_id: Some("cand_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_type: Some("openai".to_string()),
|
||||
provider_id: Some("prov_456".to_string()),
|
||||
endpoint_id: Some("ep_456".to_string()),
|
||||
key_id: Some("key_456".to_string()),
|
||||
@@ -440,6 +442,7 @@ mod tests {
|
||||
request_id: Some("req_789".to_string()),
|
||||
candidate_id: Some("cand_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_type: Some("anthropic".to_string()),
|
||||
provider_id: Some("prov_789".to_string()),
|
||||
endpoint_id: Some("ep_789".to_string()),
|
||||
key_id: Some("key_789".to_string()),
|
||||
|
||||
+10
-5
@@ -9,7 +9,7 @@ use crate::ai_serving::planner::report_context::{
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
@@ -204,7 +204,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
|
||||
image_request_summary: _,
|
||||
request_redacted: _,
|
||||
} = resolved;
|
||||
let request_gzip = resolve_transport_request_gzip_policy(&transport);
|
||||
let request_encoding = resolve_transport_request_encoding_policy(&transport);
|
||||
|
||||
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
@@ -214,6 +214,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_type: transport.provider.provider_type.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
@@ -231,8 +232,8 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
request_gzip,
|
||||
content_encoding: request_encoding.content_encoding,
|
||||
request_gzip: request_encoding.request_gzip,
|
||||
proxy,
|
||||
transport_profile,
|
||||
timeouts,
|
||||
@@ -241,6 +242,10 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
});
|
||||
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
|
||||
apply_provider_request_routing_policy_to_decision(
|
||||
input,
|
||||
&mut decision,
|
||||
Some(transport.as_ref()),
|
||||
)?;
|
||||
Ok(Some(decision))
|
||||
}
|
||||
|
||||
+283
-107
@@ -27,11 +27,12 @@ use crate::ai_serving::planner::redaction::{
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_upstream_url, request_body_build_failure_extra_data,
|
||||
request_conversion_failure_extra_data,
|
||||
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
|
||||
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_cross_format_openai_responses_upstream_url,
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities,
|
||||
build_local_openai_responses_upstream_url, codex_model_capabilities_for_transport,
|
||||
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
|
||||
};
|
||||
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
|
||||
use crate::ai_serving::transport::auth::{
|
||||
@@ -58,7 +59,10 @@ use crate::ai_serving::transport::{
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
LocalResolvedOAuthRequestAuth, OpenAiImageOperation, PlannerAppState,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
use crate::{AppState, GatewayError};
|
||||
@@ -282,13 +286,26 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
let auth_header = prepared_candidate.auth_header;
|
||||
let auth_value = prepared_candidate.auth_value;
|
||||
let mapped_model = prepared_candidate.mapped_model;
|
||||
let enable_model_directives =
|
||||
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await;
|
||||
let model_directive_resolution = input
|
||||
.model_directive_policy
|
||||
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
|
||||
let model_directive_mapping =
|
||||
match model_directive_resolution.mapping_patch_for_mapped_model(&mapped_model) {
|
||||
Ok(mapping) => mapping,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let redaction = resolve_provider_chat_pii_redaction(
|
||||
state,
|
||||
parts,
|
||||
@@ -311,9 +328,19 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
let force_body_stream_field =
|
||||
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let source_model = body_json
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(input.requested_model.as_str());
|
||||
let codex_model_capabilities = codex_model_capabilities_for_transport(
|
||||
&transport,
|
||||
provider_api_format,
|
||||
mapped_model.as_str(),
|
||||
source_model,
|
||||
);
|
||||
let Some(mut base_provider_request_body) =
|
||||
(if is_grok && is_grok_text_provider_api_format(provider_api_format) {
|
||||
build_local_openai_responses_request_body(
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json,
|
||||
&mapped_model,
|
||||
upstream_is_stream,
|
||||
@@ -321,12 +348,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
transport.provider.provider_type.as_str(),
|
||||
spec_metadata.api_format,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
codex_model_capabilities.as_ref(),
|
||||
false,
|
||||
)
|
||||
} else if needs_bidirectional_conversion {
|
||||
build_cross_format_openai_responses_request_body(
|
||||
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json,
|
||||
&mapped_model,
|
||||
spec_metadata.api_format,
|
||||
@@ -339,12 +366,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
codex_model_capabilities.as_ref(),
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
build_local_openai_responses_request_body(
|
||||
build_local_openai_responses_request_body_with_codex_model_capabilities(
|
||||
body_json,
|
||||
&mapped_model,
|
||||
upstream_is_stream,
|
||||
@@ -356,9 +383,9 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
effective_headers,
|
||||
enable_model_directives,
|
||||
codex_model_capabilities.as_ref(),
|
||||
false,
|
||||
)
|
||||
})
|
||||
else {
|
||||
@@ -383,17 +410,10 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(mapping) =
|
||||
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
|
||||
state,
|
||||
provider_api_format,
|
||||
Some(&input.requested_model),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some(mapping) = model_directive_mapping.as_ref() {
|
||||
crate::ai_serving::apply_model_directive_mapping_patch(
|
||||
&mut base_provider_request_body,
|
||||
&mapping,
|
||||
mapping,
|
||||
);
|
||||
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
|
||||
// re-enforce stream-field policy afterward.
|
||||
@@ -411,6 +431,46 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
provider_api_format,
|
||||
Some(body_json),
|
||||
);
|
||||
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
|
||||
&mut base_provider_request_body,
|
||||
crate::ai_serving::OpenAiProviderRequestFinalization {
|
||||
source_api_format: spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
provider_type: transport.provider.provider_type.as_str(),
|
||||
provider_model: mapped_model.as_str(),
|
||||
source_model,
|
||||
body_rules: transport.endpoint.body_rules.as_ref(),
|
||||
upstream_is_stream,
|
||||
require_body_stream_field: request_requires_body_stream_field(
|
||||
body_json,
|
||||
force_body_stream_field,
|
||||
),
|
||||
},
|
||||
codex_model_capabilities.as_ref(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
mark_skipped_local_openai_responses_candidate_with_extra_data(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
request_conversion_failure_extra_data(
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
Some(mapped_model.as_str()),
|
||||
Some(parts.uri.path()),
|
||||
upstream_is_stream,
|
||||
"openai_responses_request_conversion",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
let provider_request_body = base_provider_request_body;
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
@@ -612,7 +672,11 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
if !is_grok {
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
effective_headers,
|
||||
@@ -621,9 +685,13 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_local_auth_config_header_overrides(
|
||||
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
|
||||
&mut provider_request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
mapped_model.as_str(),
|
||||
source_model,
|
||||
codex_model_capabilities.as_ref(),
|
||||
);
|
||||
}
|
||||
request_identity_response_encoding_when_redacted(
|
||||
@@ -788,7 +856,11 @@ async fn build_antigravity_openai_responses_payload_parts(
|
||||
}
|
||||
};
|
||||
let mut provider_request_headers = resolved.headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&resolved.body,
|
||||
effective_headers,
|
||||
@@ -797,10 +869,6 @@ async fn build_antigravity_openai_responses_payload_parts(
|
||||
Some(trace_id),
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
request_identity_response_encoding_when_redacted(
|
||||
&mut provider_request_headers,
|
||||
@@ -941,7 +1009,11 @@ async fn build_gemini_cli_openai_responses_payload_parts(
|
||||
}
|
||||
};
|
||||
let mut provider_request_headers = resolved.headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&resolved.body,
|
||||
effective_headers,
|
||||
@@ -950,10 +1022,6 @@ async fn build_gemini_cli_openai_responses_payload_parts(
|
||||
Some(trace_id),
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
resolved.transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
request_identity_response_encoding_when_redacted(
|
||||
&mut provider_request_headers,
|
||||
request_redacted,
|
||||
@@ -1182,11 +1250,16 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("chatgpt_web");
|
||||
let is_codex = transport
|
||||
.provider
|
||||
.provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex");
|
||||
let upstream_is_stream = resolve_upstream_is_stream_for_provider(
|
||||
transport.endpoint.config.as_ref(),
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
spec_metadata.require_streaming,
|
||||
spec_metadata.require_streaming && candidate.supports_streaming,
|
||||
false,
|
||||
);
|
||||
let Some((mut provider_request_body, image_request_summary)) = (if is_chatgpt_web {
|
||||
@@ -1197,7 +1270,7 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
} else {
|
||||
build_openai_image_provider_body_from_openai_responses_body(
|
||||
body_json,
|
||||
&input.requested_model,
|
||||
&prepared_candidate.mapped_model,
|
||||
upstream_is_stream,
|
||||
)
|
||||
}) else {
|
||||
@@ -1218,25 +1291,31 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let operation = openai_image_operation_from_summary(&image_request_summary)?;
|
||||
if !is_chatgpt_web {
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
Some(candidate.key_id.as_str()),
|
||||
);
|
||||
provider_request_body = project_openai_image_api_request_body(
|
||||
&provider_request_body,
|
||||
&prepared_candidate.mapped_model,
|
||||
operation,
|
||||
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
|
||||
transport.provider.provider_type.as_str(),
|
||||
Some(prepared_candidate.mapped_model.as_str()),
|
||||
),
|
||||
)?;
|
||||
}
|
||||
if is_codex {
|
||||
provider_request_body =
|
||||
project_codex_openai_image_api_request_body(&provider_request_body, operation)?;
|
||||
}
|
||||
|
||||
let upstream_url = if is_chatgpt_web {
|
||||
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
|
||||
} else {
|
||||
build_openai_image_upstream_url(
|
||||
transport,
|
||||
Some("/v1/images/generations"),
|
||||
parts.uri.query(),
|
||||
)
|
||||
let request_path = match operation {
|
||||
OpenAiImageOperation::Generate => "/v1/images/generations",
|
||||
OpenAiImageOperation::Edit => "/v1/images/edits",
|
||||
};
|
||||
build_openai_image_upstream_url(transport, Some(request_path), parts.uri.query())
|
||||
};
|
||||
let Some(mut provider_request_headers) =
|
||||
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
||||
@@ -1244,7 +1323,13 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
headers: &parts.headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
accept: "text/event-stream",
|
||||
accept: if is_codex {
|
||||
None
|
||||
} else if upstream_is_stream {
|
||||
Some("text/event-stream")
|
||||
} else {
|
||||
Some("application/json")
|
||||
},
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
@@ -1270,7 +1355,11 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
if is_chatgpt_web {
|
||||
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
|
||||
} else {
|
||||
apply_codex_openai_responses_special_headers(
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_codex_openai_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
@@ -1279,10 +1368,6 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
apply_local_auth_config_header_overrides(
|
||||
&mut provider_request_headers,
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
let (execution_strategy, conversion_mode) =
|
||||
@@ -1314,59 +1399,68 @@ fn build_openai_image_provider_body_from_openai_responses_body(
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<(Value, Value)> {
|
||||
let object = body_json.as_object()?;
|
||||
let input = object.get("input")?.clone();
|
||||
let tool = openai_responses_image_generation_tool(object);
|
||||
let (prompt, images) = collect_openai_responses_image_prompt_and_images(object.get("input"))?;
|
||||
let operation = if images.is_empty() {
|
||||
OpenAiImageOperation::Generate
|
||||
} else {
|
||||
OpenAiImageOperation::Edit
|
||||
};
|
||||
if let Some(action) = tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.get("action"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let expected = operation.as_str();
|
||||
if !action.trim().eq_ignore_ascii_case(expected) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("input".to_string(), input);
|
||||
if let Some(model) = object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
let requested_model = requested_model.trim();
|
||||
(!requested_model.is_empty()).then_some(requested_model)
|
||||
})
|
||||
{
|
||||
body.insert("model".to_string(), Value::String(model.to_string()));
|
||||
let requested_model = requested_model.trim();
|
||||
if requested_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
body.insert(
|
||||
"model".to_string(),
|
||||
Value::String(requested_model.to_string()),
|
||||
);
|
||||
body.insert("prompt".to_string(), Value::String(prompt));
|
||||
for key in [
|
||||
"background",
|
||||
"quality",
|
||||
"size",
|
||||
"output_format",
|
||||
"output_compression",
|
||||
"moderation",
|
||||
"input_fidelity",
|
||||
"partial_images",
|
||||
"n",
|
||||
"user",
|
||||
"metadata",
|
||||
"include",
|
||||
"parallel_tool_calls",
|
||||
"store",
|
||||
] {
|
||||
if let Some(value) = object.get(key) {
|
||||
if let Some(value) = tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.get(key))
|
||||
.or_else(|| object.get(key))
|
||||
{
|
||||
body.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if operation == OpenAiImageOperation::Edit {
|
||||
let image_urls = openai_image_inputs_as_api_urls(&images);
|
||||
if image_urls.len() != images.len() {
|
||||
return None;
|
||||
}
|
||||
body.insert("images".to_string(), Value::Array(image_urls));
|
||||
}
|
||||
if upstream_is_stream {
|
||||
body.insert("stream".to_string(), Value::Bool(true));
|
||||
} else if let Some(value) = object.get("stream") {
|
||||
body.insert("stream".to_string(), value.clone());
|
||||
}
|
||||
let image_tool = tool.clone().unwrap_or_else(|| {
|
||||
let mut tool = serde_json::Map::new();
|
||||
tool.insert(
|
||||
"type".to_string(),
|
||||
Value::String("image_generation".to_string()),
|
||||
);
|
||||
tool
|
||||
});
|
||||
body.insert(
|
||||
"tools".to_string(),
|
||||
Value::Array(vec![Value::Object(image_tool)]),
|
||||
);
|
||||
|
||||
let mut summary = serde_json::Map::new();
|
||||
summary.insert(
|
||||
"operation".to_string(),
|
||||
tool.as_ref()
|
||||
.and_then(|tool| tool.get("action"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!("generate")),
|
||||
Value::String(operation.as_str().to_string()),
|
||||
);
|
||||
for key in ["output_format", "partial_images", "size", "quality"] {
|
||||
let tool_value = tool.as_ref().and_then(|tool| tool.get(key));
|
||||
@@ -1378,6 +1472,14 @@ fn build_openai_image_provider_body_from_openai_responses_body(
|
||||
Some((Value::Object(body), Value::Object(summary)))
|
||||
}
|
||||
|
||||
fn openai_image_operation_from_summary(summary: &Value) -> Option<OpenAiImageOperation> {
|
||||
match summary.get("operation")?.as_str()? {
|
||||
"generate" => Some(OpenAiImageOperation::Generate),
|
||||
"edit" => Some(OpenAiImageOperation::Edit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_responses_image_generation_tool(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
) -> Option<serde_json::Map<String, Value>> {
|
||||
@@ -1572,6 +1674,20 @@ fn openai_image_inputs_as_urls(images: &[Value]) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn openai_image_inputs_as_api_urls(images: &[Value]) -> Vec<Value> {
|
||||
images
|
||||
.iter()
|
||||
.filter_map(|image| {
|
||||
image
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| json!({ "image_url": value }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn chatgpt_web_ratio_for_size(size: &str) -> String {
|
||||
let Some((width, height)) = size.split_once('x') else {
|
||||
return "1:1".to_string();
|
||||
@@ -1769,7 +1885,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn openai_responses_image_bridge_body_preserves_image_generation_tool() {
|
||||
fn openai_responses_image_bridge_builds_images_api_body() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-image-2",
|
||||
"input": "Draw a glass city",
|
||||
@@ -1792,14 +1908,74 @@ mod tests {
|
||||
)
|
||||
.expect("responses image body should convert");
|
||||
|
||||
assert_eq!(provider_body["tools"][0]["type"], "image_generation");
|
||||
assert_eq!(provider_body["tools"][0]["size"], "1024x1024");
|
||||
assert_eq!(provider_body["tools"][0]["output_format"], "png");
|
||||
assert_eq!(provider_body["model"], "gpt-image-2");
|
||||
assert_eq!(provider_body["input"], "Draw a glass city");
|
||||
assert_eq!(provider_body["prompt"], "Draw a glass city");
|
||||
assert_eq!(provider_body["size"], "1024x1024");
|
||||
assert_eq!(provider_body["output_format"], "png");
|
||||
assert_eq!(provider_body["stream"], true);
|
||||
assert!(provider_body.get("tools").is_none());
|
||||
assert!(provider_body.get("input").is_none());
|
||||
assert_eq!(summary["operation"], "generate");
|
||||
assert_eq!(summary["output_format"], "png");
|
||||
|
||||
let (sync_provider_body, _) = build_openai_image_provider_body_from_openai_responses_body(
|
||||
&body_json,
|
||||
"gpt-image-2",
|
||||
false,
|
||||
)
|
||||
.expect("responses image body should convert for a sync upstream");
|
||||
assert!(sync_provider_body.get("stream").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_image_bridge_uses_the_shared_mapped_model_projection() {
|
||||
let body_json = json!({
|
||||
"model": "image-alias",
|
||||
"input": "Draw a glass city",
|
||||
"tools": [{
|
||||
"type": "image_generation",
|
||||
"quality": "high",
|
||||
"n": 2
|
||||
}],
|
||||
"tool_choice": {"type": "image_generation"}
|
||||
});
|
||||
let (body, _) = build_openai_image_provider_body_from_openai_responses_body(
|
||||
&body_json, "dall-e-3", false,
|
||||
)
|
||||
.expect("Responses image body should convert before provider projection");
|
||||
|
||||
assert!(project_openai_image_api_request_body(
|
||||
&body,
|
||||
"dall-e-3",
|
||||
OpenAiImageOperation::Generate,
|
||||
1,
|
||||
)
|
||||
.is_none());
|
||||
let single = json!({
|
||||
"model": "dall-e-3",
|
||||
"prompt": "Draw a glass city",
|
||||
"quality": "high",
|
||||
"n": 1
|
||||
});
|
||||
let projected = project_openai_image_api_request_body(
|
||||
&single,
|
||||
"dall-e-3",
|
||||
OpenAiImageOperation::Generate,
|
||||
1,
|
||||
)
|
||||
.expect("DALL-E 3 single image request should project");
|
||||
assert_eq!(projected["quality"], "hd");
|
||||
|
||||
let codex_overflow = json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "Draw a glass city",
|
||||
"n": 11
|
||||
});
|
||||
assert!(project_codex_openai_image_api_request_body(
|
||||
&codex_overflow,
|
||||
OpenAiImageOperation::Generate
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
@@ -91,7 +91,9 @@ pub(crate) async fn resolve_local_openai_responses_decision_input(
|
||||
state,
|
||||
auth_context.clone(),
|
||||
Some(requested_model.as_str()),
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
None,
|
||||
&decision.model_directive_policy,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -171,6 +173,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
|
||||
);
|
||||
let preselection = preselect_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
spec_metadata.require_streaming,
|
||||
@@ -280,6 +283,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>(
|
||||
Ok(
|
||||
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
@@ -365,6 +369,7 @@ pub(crate) async fn build_local_openai_responses_image_candidate_attempt_source<
|
||||
);
|
||||
let preselection = preselect_local_execution_candidates_for_api_formats_with_serving(
|
||||
planner_state,
|
||||
&input.model_directive_policy,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
false,
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(crate) fn build_standard_stream_plan_from_decision(
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
@@ -155,7 +156,7 @@ pub(crate) fn build_standard_stream_plan_from_decision(
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user