mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
refactor ai serving modules and crates
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
resolve_claude_stream_spec as resolve_surface_stream_spec,
|
||||
resolve_claude_sync_spec as resolve_surface_sync_spec,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::family::{
|
||||
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::normalize_claude_request_to_openai_chat_request;
|
||||
|
||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||
resolve_surface_sync_spec(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||
resolve_surface_stream_spec(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_claude_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
maybe_build_sync_via_standard_family_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
plan_kind,
|
||||
resolve_sync_spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_claude_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
maybe_build_stream_via_standard_family_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
plan_kind,
|
||||
resolve_stream_spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#[cfg(test)]
|
||||
#[path = "codex/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"max_output_tokens": 128,
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.9,
|
||||
"metadata": {"client": "desktop"},
|
||||
"store": true
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(body.get("max_output_tokens").is_none());
|
||||
assert!(body.get("temperature").is_none());
|
||||
assert!(body.get("top_p").is_none());
|
||||
assert!(body.get("metadata").is_none());
|
||||
assert_eq!(body["store"], false);
|
||||
assert_eq!(body["instructions"], "You are ChatGPT.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_store_for_compact_even_when_body_rules_handle_it() {
|
||||
let body_rules = json!([
|
||||
{"action":"set","path":"store","value":true},
|
||||
{"action":"set","path":"instructions","value":"Keep custom"},
|
||||
{"action":"set","path":"metadata","value":{"client":"desktop","mode":"custom"}},
|
||||
{"action":"set","path":"top_p","value":0.5}
|
||||
]);
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"max_output_tokens": 128,
|
||||
"metadata": {"client": "desktop", "mode": "custom"},
|
||||
"store": true,
|
||||
"instructions": "Keep custom",
|
||||
"top_p": 0.5
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
Some(&body_rules),
|
||||
None,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_stable_prompt_cache_key_for_codex_requests() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
body["prompt_cache_key"],
|
||||
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_prompt_cache_key_for_codex_requests() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
"prompt_cache_key": "existing-key",
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
);
|
||||
|
||||
assert_eq!(body["prompt_cache_key"], "existing-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_chatgpt_account_id_and_session_headers_for_codex_requests() {
|
||||
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(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
);
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_existing_codex_request_and_session_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());
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
|
||||
});
|
||||
let mut original_headers = HeaderMap::new();
|
||||
original_headers.insert(
|
||||
"x-client-request-id",
|
||||
HeaderValue::from_static("user-specified-request"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"session_id",
|
||||
HeaderValue::from_static("user-specified-session"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"conversation_id",
|
||||
HeaderValue::from_static("user-specified-conversation"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("user-specified-agent"),
|
||||
);
|
||||
original_headers.insert(
|
||||
"originator",
|
||||
HeaderValue::from_static("user-specified-originator"),
|
||||
);
|
||||
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut headers,
|
||||
&body,
|
||||
&original_headers,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
Some("trace-codex-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("x-client-request-id"),
|
||||
Some(&"kept-by-rule-request".to_string())
|
||||
);
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_conversation_id_for_compact_codex_requests() {
|
||||
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(
|
||||
&mut headers,
|
||||
&body,
|
||||
&HeaderMap::new(),
|
||||
"codex",
|
||||
"openai:responses:compact",
|
||||
Some("trace-codex-compact-123"),
|
||||
Some(r#"{"account_id":"acc-123"}"#),
|
||||
);
|
||||
|
||||
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("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!(!headers.contains_key("conversation_id"));
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::common::extract_requested_model_from_request;
|
||||
use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt};
|
||||
use crate::ai_serving::planner::runtime_miss::{
|
||||
apply_local_runtime_candidate_evaluation_progress,
|
||||
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::{
|
||||
build_stream_plan_from_requested_model_family, build_sync_plan_from_requested_model_family,
|
||||
local_standard_spec_metadata,
|
||||
};
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::candidates::{
|
||||
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,
|
||||
};
|
||||
use super::payload::maybe_build_local_standard_decision_payload_for_candidate;
|
||||
use super::LocalStandardSpec;
|
||||
|
||||
pub(crate) async fn maybe_build_sync_via_standard_family_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_standard_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_via_standard_family_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
|
||||
let Some(input) =
|
||||
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_standard_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("standard spec metadata should include requested-model family");
|
||||
let Some(input) =
|
||||
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
|
||||
.await
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_standard_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let built = build_sync_plan_from_requested_model_family(
|
||||
requested_model_family,
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
);
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local standard sync plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
let requested_model_family = spec_metadata
|
||||
.requested_model_family
|
||||
.expect("standard spec metadata should include requested-model family");
|
||||
let Some(input) =
|
||||
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
|
||||
.await
|
||||
else {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
extract_requested_model_from_request(parts, body_json, requested_model_family)
|
||||
.as_deref(),
|
||||
"decision_input_unavailable",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
let (attempts, candidate_count) =
|
||||
materialize_local_standard_candidate_attempts(state, trace_id, &input, body_json, spec)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let built = build_stream_plan_from_requested_model_family(
|
||||
requested_model_family,
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
);
|
||||
match built {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local standard stream plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_contract_metadata,
|
||||
build_local_execution_candidate_contract_metadata_for_candidate,
|
||||
LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_source::{
|
||||
preselect_local_execution_candidates_with_serving, LocalCandidatePreselectionKeyMode,
|
||||
};
|
||||
use crate::ai_serving::planner::common::extract_requested_model_from_request;
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision, PlannerAppState,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
|
||||
|
||||
pub(super) async fn resolve_local_standard_decision_input(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Option<LocalStandardDecisionInput> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let requested_model = extract_requested_model_from_request(
|
||||
parts,
|
||||
body_json,
|
||||
spec_metadata
|
||||
.requested_model_family
|
||||
.expect("standard specs should declare requested-model family"),
|
||||
)?;
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context,
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => return None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local standard decision auth snapshot read failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(build_local_requested_model_decision_input(
|
||||
resolved_input,
|
||||
requested_model,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalStandardDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Result<(Vec<LocalStandardCandidateAttempt>, usize), GatewayError> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::StandardDecision,
|
||||
);
|
||||
let preselection = preselect_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
spec_metadata.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
&input.auth_snapshot,
|
||||
false,
|
||||
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.required_capabilities.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
persistence_policy,
|
||||
preselection.candidates,
|
||||
preselection.skipped_candidates,
|
||||
LocalCandidateResolutionMode::Standard,
|
||||
|eligible| {
|
||||
let provider_api_format = eligible.provider_api_format.clone();
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
&provider_api_format,
|
||||
);
|
||||
Some(build_local_execution_candidate_contract_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
client_api_format: spec_metadata.api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
eligible.candidate.endpoint_api_format.as_str(),
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
skipped_candidate
|
||||
.candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
&provider_api_format,
|
||||
);
|
||||
skipped_candidate.extra_data = Some(
|
||||
build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
spec_metadata.api_format,
|
||||
serde_json::Map::new(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
);
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((outcome.attempts, outcome.candidate_count))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod build;
|
||||
mod candidates;
|
||||
mod payload;
|
||||
mod request;
|
||||
|
||||
pub(crate) use self::build::{
|
||||
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports,
|
||||
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
||||
};
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalStandardCandidateAttempt;
|
||||
pub(super) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalStandardDecisionInput;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
@@ -0,0 +1,554 @@
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::passthrough::maybe_build_local_same_format_provider_decision_payload_for_candidate;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, LocalExecutionReportContextParts,
|
||||
};
|
||||
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, AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, api_format_alias_matches,
|
||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value,
|
||||
AiExecutionDecision, AppState,
|
||||
};
|
||||
|
||||
use super::request::resolve_local_standard_candidate_payload_parts;
|
||||
use super::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
|
||||
|
||||
pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalStandardDecisionInput,
|
||||
attempt: LocalStandardCandidateAttempt,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Option<AiExecutionDecision> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
if api_format_alias_matches(
|
||||
&attempt.eligible.provider_api_format,
|
||||
spec_metadata.api_format,
|
||||
) {
|
||||
let same_format_spec = if spec_metadata.require_streaming {
|
||||
resolve_local_same_format_stream_spec(spec_metadata.decision_kind)
|
||||
} else {
|
||||
resolve_local_same_format_sync_spec(spec_metadata.decision_kind)
|
||||
};
|
||||
if let Some(same_format_spec) = same_format_spec {
|
||||
return maybe_build_local_same_format_provider_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
attempt,
|
||||
same_format_spec,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let LocalStandardCandidateAttempt {
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
..
|
||||
} = &attempt;
|
||||
let candidate = &eligible.candidate;
|
||||
let resolved = resolve_local_standard_candidate_payload_parts(
|
||||
state, parts, trace_id, body_json, input, &attempt, spec,
|
||||
)
|
||||
.await?;
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) =
|
||||
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||
{
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
if let Some(envelope_name) = resolved.envelope_name {
|
||||
extra_fields.insert(
|
||||
"envelope_name".to_string(),
|
||||
serde_json::Value::String(envelope_name.to_string()),
|
||||
);
|
||||
}
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
resolved.provider_api_format.as_str(),
|
||||
);
|
||||
let report_context = append_local_failover_policy_to_value(
|
||||
append_execution_contract_fields_to_value(
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id,
|
||||
attempt_identity: attempt.attempt_identity(),
|
||||
model: &input.requested_model,
|
||||
provider_name: &candidate.provider_name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: Some(&candidate.key_name),
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: &resolved.provider_api_format,
|
||||
client_api_format: spec_metadata.api_format,
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: Some(&resolved.upstream_url),
|
||||
header_rules: resolved.transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: resolved.transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: Some(serde_json::Value::Null),
|
||||
provider_request_headers: Some(&resolved.provider_request_headers),
|
||||
original_headers: &parts.headers,
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: None,
|
||||
client_requested_stream: body_json
|
||||
.get("stream")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
upstream_is_stream: resolved.upstream_is_stream,
|
||||
has_envelope: resolved.envelope_name.is_some(),
|
||||
needs_conversion: true,
|
||||
extra_fields,
|
||||
}),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
resolved.provider_api_format.as_str(),
|
||||
),
|
||||
&resolved.transport,
|
||||
);
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||
let super::request::LocalStandardCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
upstream_is_stream,
|
||||
envelope_name: _,
|
||||
transport,
|
||||
} = resolved;
|
||||
|
||||
Some(build_ai_execution_decision_response(
|
||||
AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.to_string(),
|
||||
provider_name: candidate.provider_name.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: None,
|
||||
auth_header: Some(auth_header),
|
||||
auth_value: Some(auth_value),
|
||||
provider_api_format,
|
||||
client_api_format: spec_metadata.api_format.to_string(),
|
||||
model_name: input.requested_model.clone(),
|
||||
mapped_model,
|
||||
prompt_cache_key: None,
|
||||
provider_request_headers,
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy,
|
||||
tls_profile,
|
||||
timeouts,
|
||||
upstream_is_stream,
|
||||
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn mark_skipped_local_standard_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalStandardDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::StandardDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn mark_skipped_local_standard_candidate_with_extra_data(
|
||||
state: &AppState,
|
||||
input: &LocalStandardDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
extra_data: Option<serde_json::Value>,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::StandardDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_extra_data(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
extra_data,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalStandardDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
&input.auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::StandardDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use serde_json::json;
|
||||
|
||||
use super::maybe_build_local_standard_decision_payload_for_candidate;
|
||||
use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt;
|
||||
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput;
|
||||
use crate::ai_serving::{
|
||||
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, LocalStandardSourceFamily,
|
||||
LocalStandardSourceMode, LocalStandardSpec,
|
||||
};
|
||||
use crate::orchestration::LocalExecutionCandidateMetadata;
|
||||
|
||||
fn sample_auth_snapshot() -> GatewayAuthApiKeySnapshot {
|
||||
GatewayAuthApiKeySnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
username: "alice".to_string(),
|
||||
email: None,
|
||||
user_role: "user".to_string(),
|
||||
user_auth_source: "local".to_string(),
|
||||
user_is_active: true,
|
||||
user_is_deleted: false,
|
||||
user_rate_limit: None,
|
||||
user_allowed_providers: None,
|
||||
user_allowed_api_formats: None,
|
||||
user_allowed_models: None,
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
api_key_name: Some("default".to_string()),
|
||||
api_key_is_active: true,
|
||||
api_key_is_locked: false,
|
||||
api_key_is_standalone: false,
|
||||
api_key_rate_limit: None,
|
||||
api_key_concurrent_limit: None,
|
||||
api_key_expires_at_unix_secs: None,
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_input() -> LocalRequestedModelDecisionInput {
|
||||
LocalRequestedModelDecisionInput {
|
||||
auth_context: ExecutionRuntimeAuthContext {
|
||||
user_id: "user-1".to_string(),
|
||||
api_key_id: "api-key-1".to_string(),
|
||||
username: Some("alice".to_string()),
|
||||
api_key_name: Some("default".to_string()),
|
||||
balance_remaining: Some(10.0),
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
},
|
||||
requested_model: "claude-sonnet-4-5".to_string(),
|
||||
auth_snapshot: sample_auth_snapshot(),
|
||||
required_capabilities: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_transport(api_format: &str, endpoint_id: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: endpoint_id.to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: api_format.to_string(),
|
||||
api_family: Some(
|
||||
api_format
|
||||
.split_once(':')
|
||||
.map(|(family, _)| family)
|
||||
.unwrap_or(api_format)
|
||||
.to_string(),
|
||||
),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://api.example.test".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: if api_format == "openai:chat" {
|
||||
Some(json!({
|
||||
"enabled": true,
|
||||
"accept_formats": ["claude:messages"],
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "key".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec![
|
||||
"claude:messages".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
]),
|
||||
auth_type_by_format: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: Some(json!({
|
||||
"claude:messages": 1,
|
||||
"openai:chat": 1,
|
||||
})),
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "sk-upstream".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_candidate(
|
||||
api_format: &str,
|
||||
endpoint_id: &str,
|
||||
) -> SchedulerMinimalCandidateSelectionCandidate {
|
||||
SchedulerMinimalCandidateSelectionCandidate {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority: 1,
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
endpoint_api_format: api_format.to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "key".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_internal_priority: 1,
|
||||
key_global_priority_for_format: Some(1),
|
||||
key_capabilities: None,
|
||||
model_id: format!("model-{endpoint_id}"),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "claude-sonnet-4-5".to_string(),
|
||||
selected_provider_model_name: if api_format == "claude:messages" {
|
||||
"claude-sonnet-4-5-upstream".to_string()
|
||||
} else {
|
||||
"gpt-4o-upstream".to_string()
|
||||
},
|
||||
mapping_matched_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_attempt(
|
||||
api_format: &str,
|
||||
endpoint_id: &str,
|
||||
candidate_index: u32,
|
||||
) -> LocalExecutionCandidateAttempt {
|
||||
LocalExecutionCandidateAttempt {
|
||||
eligible: EligibleLocalExecutionCandidate {
|
||||
candidate: sample_candidate(api_format, endpoint_id),
|
||||
transport: Arc::new(sample_transport(api_format, endpoint_id)),
|
||||
provider_api_format: api_format.to_string(),
|
||||
orchestration: LocalExecutionCandidateMetadata::default(),
|
||||
ranking: None,
|
||||
},
|
||||
candidate_index,
|
||||
retry_index: 0,
|
||||
candidate_id: format!("candidate-{candidate_index}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_stream_spec() -> LocalStandardSpec {
|
||||
LocalStandardSpec {
|
||||
api_format: "claude:messages",
|
||||
decision_kind: "claude_chat_stream",
|
||||
report_kind: "claude_chat_stream_success",
|
||||
family: LocalStandardSourceFamily::Standard,
|
||||
mode: LocalStandardSourceMode::Chat,
|
||||
require_streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_family_builds_same_format_candidate_before_cross_format_candidate() {
|
||||
let state = crate::AppState::new().expect("state should build");
|
||||
let request = http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/messages?beta=true")
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
let body_json = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"max_tokens": 32,
|
||||
"stream": true
|
||||
});
|
||||
let input = sample_input();
|
||||
|
||||
let payload = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-standard-same-format-first",
|
||||
&body_json,
|
||||
&input,
|
||||
sample_attempt("claude:messages", "endpoint-claude", 0),
|
||||
claude_stream_spec(),
|
||||
)
|
||||
.await
|
||||
.expect("same-format candidate should build a standard-family payload");
|
||||
|
||||
assert_eq!(payload.endpoint_id.as_deref(), Some("endpoint-claude"));
|
||||
assert_eq!(
|
||||
payload.execution_strategy.as_deref(),
|
||||
Some("local_same_format")
|
||||
);
|
||||
assert_eq!(payload.conversion_mode.as_deref(), Some("none"));
|
||||
assert_eq!(
|
||||
payload.provider_api_format.as_deref(),
|
||||
Some("claude:messages")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.client_api_format.as_deref(),
|
||||
Some("claude:messages")
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.provider_request_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("model"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("claude-sonnet-4-5-upstream")
|
||||
);
|
||||
|
||||
let cross_format_payload = maybe_build_local_standard_decision_payload_for_candidate(
|
||||
&state,
|
||||
&parts,
|
||||
"trace-standard-same-format-first",
|
||||
&body_json,
|
||||
&input,
|
||||
sample_attempt("openai:chat", "endpoint-openai-chat", 1),
|
||||
claude_stream_spec(),
|
||||
)
|
||||
.await
|
||||
.expect("cross-format candidate should still build after the same-format candidate");
|
||||
|
||||
assert_eq!(
|
||||
cross_format_payload.endpoint_id.as_deref(),
|
||||
Some("endpoint-openai-chat")
|
||||
);
|
||||
assert_eq!(
|
||||
cross_format_payload.execution_strategy.as_deref(),
|
||||
Some("local_cross_format")
|
||||
);
|
||||
assert_eq!(
|
||||
cross_format_payload.conversion_mode.as_deref(),
|
||||
Some("bidirectional")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth,
|
||||
OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::common::force_upstream_streaming_for_provider;
|
||||
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_headers, request_body_build_failure_extra_data,
|
||||
};
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
|
||||
KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_kiro_cross_format_upstream_url, build_standard_provider_request_headers,
|
||||
StandardProviderRequestHeadersInput,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
use super::payload::{
|
||||
mark_skipped_local_standard_candidate, mark_skipped_local_standard_candidate_with_extra_data,
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic,
|
||||
};
|
||||
use super::{LocalStandardCandidateAttempt, LocalStandardDecisionInput, LocalStandardSpec};
|
||||
|
||||
pub(crate) struct LocalStandardCandidatePayloadParts {
|
||||
pub(super) auth_header: String,
|
||||
pub(super) auth_value: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) provider_request_body: Value,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) upstream_is_stream: bool,
|
||||
pub(super) envelope_name: Option<&'static str>,
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_local_standard_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalStandardDecisionInput,
|
||||
attempt: &LocalStandardCandidateAttempt,
|
||||
spec: LocalStandardSpec,
|
||||
) -> Option<LocalStandardCandidatePayloadParts> {
|
||||
let spec_metadata = local_standard_spec_metadata(spec);
|
||||
let planner_state = crate::ai_serving::PlannerAppState::new(state);
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let transport = &attempt.eligible.transport;
|
||||
let provider_api_format = attempt.eligible.provider_api_format.as_str();
|
||||
let is_kiro_claude_cli = is_kiro_claude_messages_transport(transport, provider_api_format);
|
||||
let Some(conversion_kind) =
|
||||
crate::ai_serving::request_conversion_kind(spec_metadata.api_format, provider_api_format)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(skip_reason) = crate::ai_serving::request_conversion_transport_unsupported_reason(
|
||||
transport,
|
||||
conversion_kind,
|
||||
) {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let oauth_context = OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: provider_api_format,
|
||||
operation: "standard_family_cross_format",
|
||||
};
|
||||
let kiro_auth = if is_kiro_claude_cli {
|
||||
match crate::ai_serving::planner::candidate_preparation::resolve_candidate_oauth_auth(
|
||||
planner_state,
|
||||
transport,
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
|
||||
_ => {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
match prepare_header_authenticated_candidate_from_auth(
|
||||
candidate,
|
||||
kiro_auth.name.to_string(),
|
||||
kiro_auth.value.clone(),
|
||||
) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match prepare_header_authenticated_candidate(
|
||||
planner_state,
|
||||
transport,
|
||||
candidate,
|
||||
crate::ai_serving::request_conversion_direct_auth(transport, conversion_kind),
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_standard_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let upstream_is_stream = spec_metadata.require_streaming
|
||||
|| force_upstream_streaming_for_provider(
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
);
|
||||
let provider_request_body =
|
||||
match crate::ai_serving::planner::standard::build_standard_request_body(
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
&prepared_candidate.mapped_model,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
parts.uri.path(),
|
||||
upstream_is_stream,
|
||||
if is_kiro_claude_cli {
|
||||
None
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
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_body_build_failure_extra_data(
|
||||
body_json,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
return build_kiro_cross_format_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
attempt,
|
||||
transport,
|
||||
provider_api_format,
|
||||
prepared_candidate.mapped_model,
|
||||
prepared_candidate.auth_header,
|
||||
prepared_candidate.auth_value,
|
||||
provider_request_body,
|
||||
upstream_is_stream,
|
||||
kiro_auth,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let upstream_url = match crate::ai_serving::planner::standard::build_standard_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
&prepared_candidate.mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"standard_family_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport,
|
||||
provider_api_format,
|
||||
same_format: false,
|
||||
headers: &parts.headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"standard_family_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
|
||||
Some(LocalStandardCandidatePayloadParts {
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
mapped_model: prepared_candidate.mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
upstream_is_stream,
|
||||
envelope_name: None,
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_kiro_cross_format_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
original_body_json: &serde_json::Value,
|
||||
input: &LocalStandardDecisionInput,
|
||||
attempt: &LocalStandardCandidateAttempt,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
provider_api_format: &str,
|
||||
mapped_model: String,
|
||||
auth_header: String,
|
||||
auth_value: String,
|
||||
claude_request_body: Value,
|
||||
upstream_is_stream: bool,
|
||||
kiro_auth: &KiroRequestAuth,
|
||||
) -> Option<LocalStandardCandidatePayloadParts> {
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let provider_request_body = match build_kiro_provider_request_body(
|
||||
&claude_request_body,
|
||||
&mapped_model,
|
||||
&kiro_auth.auth_config,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
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_body_build_failure_extra_data(
|
||||
&claude_request_body,
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let upstream_url = match build_kiro_cross_format_upstream_url(
|
||||
transport,
|
||||
&mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
kiro_auth.auth_config.effective_api_region(),
|
||||
) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
"standard_family_kiro_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &parts.headers,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: original_body_json,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
auth_config: &kiro_auth.auth_config,
|
||||
machine_id: kiro_auth.machine_id.as_str(),
|
||||
}) {
|
||||
Some(headers) => headers,
|
||||
None => {
|
||||
mark_skipped_local_standard_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
"standard_family_kiro_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(LocalStandardCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
upstream_is_stream,
|
||||
envelope_name: Some(KIRO_ENVELOPE_NAME),
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::ai_serving::{
|
||||
resolve_gemini_stream_spec as resolve_surface_stream_spec,
|
||||
resolve_gemini_sync_spec as resolve_surface_sync_spec,
|
||||
};
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
use super::family::{
|
||||
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::normalize_gemini_request_to_openai_chat_request;
|
||||
|
||||
pub(crate) fn resolve_sync_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||
resolve_surface_sync_spec(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_stream_spec(plan_kind: &str) -> Option<super::family::LocalStandardSpec> {
|
||||
resolve_surface_stream_spec(plan_kind)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_gemini_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
maybe_build_sync_via_standard_family_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
plan_kind,
|
||||
resolve_sync_spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_gemini_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
maybe_build_stream_via_standard_family_payload(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
plan_kind,
|
||||
resolve_stream_spec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::RequestBody;
|
||||
|
||||
use super::{
|
||||
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||
generic_decision_missing_exact_provider_request, take_ai_decision_plan_core,
|
||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_standard_plan_fallback_headers, StandardPlanFallbackAcceptPolicy,
|
||||
StandardPlanFallbackHeadersInput,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
pub(crate) fn build_gemini_sync_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: payload.upstream_is_stream,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamIfStreaming,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiSyncAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_gemini_stream_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: true,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamRequired,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiStreamAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
320
apps/aether-gateway/src/ai_serving/planner/standard/mod.rs
Normal file
320
apps/aether-gateway/src/ai_serving/planner/standard/mod.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! Standard contract planning surface.
|
||||
//!
|
||||
//! This groups the standard planning surface in one place:
|
||||
//! request-side conversion, matrix registry, and decision payload builders.
|
||||
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod family;
|
||||
mod gemini;
|
||||
mod normalize;
|
||||
mod openai;
|
||||
|
||||
pub(crate) use self::codex::apply_codex_openai_responses_special_headers;
|
||||
pub(crate) use self::family::{
|
||||
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports,
|
||||
};
|
||||
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_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,
|
||||
};
|
||||
pub(crate) use self::openai::{
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind, copy_request_number_field,
|
||||
copy_request_number_field_as, map_openai_reasoning_effort_to_claude_output,
|
||||
map_openai_reasoning_effort_to_gemini_budget, maybe_build_stream_local_decision_payload,
|
||||
maybe_build_stream_local_openai_responses_decision_payload,
|
||||
maybe_build_sync_local_decision_payload,
|
||||
maybe_build_sync_local_openai_responses_decision_payload, parse_openai_stop_sequences,
|
||||
resolve_openai_chat_max_tokens, set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
value_as_u64,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::normalize_standard_request_to_openai_chat_request;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
build_core_error_body_for_client_format, request_conversion_kind,
|
||||
request_conversion_transport_supported, sync_chat_response_conversion_kind,
|
||||
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
|
||||
SyncCliResponseConversionKind,
|
||||
};
|
||||
pub(crate) use crate::ai_serving::{
|
||||
build_standard_request_body, convert_openai_chat_request_to_claude_request,
|
||||
convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
|
||||
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
|
||||
};
|
||||
pub(crate) use aether_ai_serving::{
|
||||
request_body_build_failure_extra_data, same_format_provider_request_body_failure_extra_data,
|
||||
};
|
||||
|
||||
pub(crate) fn build_standard_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &crate::ai_serving::GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::build_provider_transport_request_url(
|
||||
transport,
|
||||
provider_api_format,
|
||||
Some(mapped_model),
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_standard_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if let Some(payload) = self::claude::maybe_build_sync_local_claude_decision_payload(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
|
||||
self::gemini::maybe_build_sync_local_gemini_decision_payload(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_standard_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if let Some(payload) = self::claude::maybe_build_stream_local_claude_decision_payload(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
|
||||
self::gemini::maybe_build_stream_local_gemini_decision_payload(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_standard_request_body;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_request_from_claude_chat_source() {
|
||||
let request = json!({
|
||||
"model": "claude-3-7-sonnet",
|
||||
"system": "You are concise.",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello from Claude"}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:messages",
|
||||
"gpt-5",
|
||||
"openai",
|
||||
"openai:chat",
|
||||
"/v1/messages",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude chat should convert to openai chat");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5");
|
||||
assert_eq!(converted["messages"][0]["role"], "system");
|
||||
assert_eq!(converted["messages"][0]["content"], "You are concise.");
|
||||
assert_eq!(converted["messages"][1]["role"], "user");
|
||||
assert_eq!(converted["messages"][1]["content"], "Hello from Claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_claude_chat_request_from_gemini_chat_source() {
|
||||
let request = json!({
|
||||
"systemInstruction": {
|
||||
"parts": [{"text": "Be brief."}]
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [{"text": "Hello from Gemini"}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"gemini:generate_content",
|
||||
"claude-sonnet-4-5",
|
||||
"anthropic",
|
||||
"claude:messages",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("gemini chat should convert to claude chat");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert!(
|
||||
converted["messages"]
|
||||
.to_string()
|
||||
.contains("Hello from Gemini"),
|
||||
"converted claude payload should retain the gemini user text: {converted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_gemini_cli_request_from_claude_cli_source() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Need CLI output"}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:messages",
|
||||
"gemini-2.5-pro",
|
||||
"google",
|
||||
"gemini:generate_content",
|
||||
"/v1/messages",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude cli should convert to gemini cli");
|
||||
|
||||
assert_eq!(converted["contents"][0]["role"], "user");
|
||||
assert_eq!(
|
||||
converted["contents"][0]["parts"][0]["text"],
|
||||
"Need CLI output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_responses_request_from_claude_cli_source_with_forced_stream() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Need OpenAI Responses output"}]
|
||||
}
|
||||
],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:messages",
|
||||
"gpt-5",
|
||||
"openai",
|
||||
"openai:responses",
|
||||
"/v1/messages",
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude cli should convert to openai responses");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5");
|
||||
assert_eq!(converted["input"][0]["role"], "user");
|
||||
assert_eq!(converted["input"][0]["content"][0]["type"], "input_text");
|
||||
assert_eq!(
|
||||
converted["input"][0]["content"][0]["text"],
|
||||
"Need OpenAI Responses output"
|
||||
);
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_metadata_for_codex_openai_responses_requests() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"metadata": {"trace_id": "abc"},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Need OpenAI Responses output"}]
|
||||
}],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"claude:messages",
|
||||
"gpt-5",
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"/v1/messages",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude cli should convert to codex request");
|
||||
|
||||
assert!(converted.get("metadata").is_none());
|
||||
assert_eq!(converted["store"], false);
|
||||
assert_eq!(converted["instructions"], "You are ChatGPT.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_store_for_openai_responses_compact_requests() {
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Hello from OpenAI Chat"
|
||||
}],
|
||||
"store": true
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"gpt-5",
|
||||
"openai",
|
||||
"openai:responses:compact",
|
||||
"/v1/chat/completions",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("openai chat should convert to openai responses compact");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5");
|
||||
assert!(converted.get("store").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#[path = "normalize/chat.rs"]
|
||||
mod chat;
|
||||
#[path = "normalize/responses.rs"]
|
||||
mod responses;
|
||||
#[cfg(test)]
|
||||
#[path = "normalize/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use self::chat::{
|
||||
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,
|
||||
};
|
||||
pub(crate) use self::responses::{
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::transport::apply_standard_provider_request_body_rules;
|
||||
use crate::ai_serving::{
|
||||
apply_codex_openai_responses_special_body_edits,
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
build_cross_format_openai_chat_request_body as surface_build_cross_format_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body as surface_build_local_openai_chat_request_body,
|
||||
GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
pub(crate) fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
body_rules: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body =
|
||||
surface_build_local_openai_chat_request_body(body_json, mapped_model, upstream_is_stream)?;
|
||||
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_openai_chat_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_local_openai_chat_upstream_url(transport, parts.uri.query())
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body = surface_build_cross_format_openai_chat_request_body(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)?;
|
||||
let mut provider_request_body =
|
||||
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_chat_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_chat_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::transport::apply_standard_provider_request_body_rules;
|
||||
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 as surface_build_cross_format_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body as surface_build_local_openai_responses_request_body,
|
||||
GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
pub(crate) fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body = surface_build_local_openai_responses_request_body(
|
||||
body_json,
|
||||
mapped_model,
|
||||
require_streaming,
|
||||
)?;
|
||||
let mut provider_request_body =
|
||||
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
provider_type: &str,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
let provider_request_body = surface_build_cross_format_openai_responses_request_body(
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)?;
|
||||
let mut provider_request_body =
|
||||
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
);
|
||||
apply_openai_responses_compact_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
);
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_openai_responses_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
compact: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_local_openai_responses_upstream_url(
|
||||
transport,
|
||||
compact,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cross_format_openai_responses_upstream_url(
|
||||
parts: &http::request::Parts,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<String> {
|
||||
crate::ai_serving::transport::build_cross_format_openai_responses_upstream_url(
|
||||
transport,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use aether_provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_transport(base_url: &str, api_format: &str) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-codex".to_string(),
|
||||
name: "codex".to_string(),
|
||||
provider_type: "codex".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: false,
|
||||
concurrent_limit: None,
|
||||
max_retries: None,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: None,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-codex".to_string(),
|
||||
provider_id: "provider-codex".to_string(),
|
||||
api_format: api_format.to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("cli".to_string()),
|
||||
is_active: true,
|
||||
base_url: base_url.to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-codex".to_string(),
|
||||
provider_id: "provider-codex".to_string(),
|
||||
name: "oauth".to_string(),
|
||||
auth_type: "oauth".to_string(),
|
||||
is_active: true,
|
||||
api_formats: Some(vec![api_format.to_string()]),
|
||||
auth_type_by_format: None,
|
||||
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "__placeholder__".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello",
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
false,
|
||||
"openai",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("openai responses to openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"text": {"format": {"type": "text"}},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto"
|
||||
}"#,
|
||||
)
|
||||
.expect("request body should parse");
|
||||
|
||||
let provider_request_body = build_local_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
true,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
)
|
||||
.expect("local openai responses body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [],
|
||||
"store": true
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
false,
|
||||
"openai",
|
||||
"openai:responses:compact",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("local openai compact body should build");
|
||||
|
||||
assert!(provider_request_body.get("store").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_upstream_url_preserves_codex_base_path() {
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build");
|
||||
let (parts, _) = request.into_parts();
|
||||
|
||||
let upstream_url = build_local_openai_responses_upstream_url(
|
||||
&parts,
|
||||
&sample_transport("https://tiger.bookapi.cc/codex", "openai:responses"),
|
||||
false,
|
||||
)
|
||||
.expect("openai responses upstream url should build");
|
||||
|
||||
assert_eq!(upstream_url, "https://tiger.bookapi.cc/codex/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_metadata_for_codex_openai_responses_requests() {
|
||||
let body_json = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"metadata": {"trace_id": "abc"},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}]
|
||||
}],
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
true,
|
||||
"codex",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("claude cli to codex request should build");
|
||||
|
||||
assert!(provider_request_body.get("metadata").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
||||
let body_json = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}]
|
||||
}],
|
||||
"metadata": {"trace_id": "abc"},
|
||||
"store": true
|
||||
});
|
||||
let body_rules = json!([
|
||||
{"action":"set","path":"store","value":true},
|
||||
{"action":"set","path":"instructions","value":"Custom instructions"},
|
||||
{"action":"set","path":"metadata","value":{"trace_id":"keep-me"}}
|
||||
]);
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
true,
|
||||
"codex",
|
||||
Some(&body_rules),
|
||||
None,
|
||||
)
|
||||
.expect("claude cli to codex request should build");
|
||||
|
||||
assert_eq!(provider_request_body["store"], true);
|
||||
assert_eq!(provider_request_body["instructions"], "Custom instructions");
|
||||
assert_eq!(provider_request_body["metadata"]["trace_id"], "keep-me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
|
||||
let body_json = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "hello"}]
|
||||
}],
|
||||
});
|
||||
|
||||
let provider_request_body = build_cross_format_openai_responses_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
true,
|
||||
"codex",
|
||||
None,
|
||||
Some("key-123"),
|
||||
)
|
||||
.expect("claude cli to codex request should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["prompt_cache_key"],
|
||||
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "hello"
|
||||
}],
|
||||
});
|
||||
|
||||
let provider_request_body = super::build_cross_format_openai_chat_request_body(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"codex",
|
||||
"openai:responses",
|
||||
false,
|
||||
None,
|
||||
Some("key-123"),
|
||||
)
|
||||
.expect("openai chat to codex request should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["prompt_cache_key"],
|
||||
"172c39e6-c0a0-5a70-8b63-e0f8e0d185a3"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#[path = "decision/payload.rs"]
|
||||
mod payload;
|
||||
#[path = "decision/request.rs"]
|
||||
mod request;
|
||||
#[path = "decision/support.rs"]
|
||||
mod support;
|
||||
|
||||
pub(super) use self::payload::maybe_build_local_openai_chat_decision_payload_for_candidate;
|
||||
pub(super) use self::support::{
|
||||
materialize_local_openai_chat_candidate_attempts, LocalOpenAiChatCandidateAttempt,
|
||||
LocalOpenAiChatDecisionInput,
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, insert_provider_stream_event_api_format,
|
||||
LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value,
|
||||
AiExecutionDecision, AppState,
|
||||
};
|
||||
|
||||
use super::request::resolve_local_openai_chat_candidate_payload_parts;
|
||||
use super::support::{LocalOpenAiChatCandidateAttempt, LocalOpenAiChatDecisionInput};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
attempt: LocalOpenAiChatCandidateAttempt,
|
||||
decision_kind: &str,
|
||||
report_kind: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<AiExecutionDecision> {
|
||||
let attempt_identity = attempt.attempt_identity();
|
||||
let LocalOpenAiChatCandidateAttempt {
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let resolved = resolve_local_openai_chat_candidate_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
&eligible,
|
||||
candidate_index,
|
||||
&candidate_id,
|
||||
decision_kind,
|
||||
report_kind,
|
||||
upstream_is_stream,
|
||||
)
|
||||
.await?;
|
||||
let candidate = &eligible.candidate;
|
||||
|
||||
let prompt_cache_key = resolved
|
||||
.provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) =
|
||||
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||
{
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
if let Some(envelope_name) = resolved.envelope_name {
|
||||
extra_fields.insert(
|
||||
"envelope_name".to_string(),
|
||||
serde_json::Value::String(envelope_name.to_string()),
|
||||
);
|
||||
}
|
||||
insert_provider_stream_event_api_format(
|
||||
&mut extra_fields,
|
||||
resolved.transport.provider.provider_type.as_str(),
|
||||
);
|
||||
let report_context = append_local_failover_policy_to_value(
|
||||
append_execution_contract_fields_to_value(
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id: &candidate_id,
|
||||
attempt_identity,
|
||||
model: &input.requested_model,
|
||||
provider_name: &resolved.transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: Some(&candidate.key_name),
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: &resolved.provider_api_format,
|
||||
client_api_format: "openai:chat",
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: Some(&resolved.upstream_url),
|
||||
header_rules: resolved.transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: resolved.transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: Some(serde_json::Value::Null),
|
||||
provider_request_headers: Some(&resolved.provider_request_headers),
|
||||
original_headers: &parts.headers,
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: None,
|
||||
client_requested_stream: body_json
|
||||
.get("stream")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
upstream_is_stream,
|
||||
has_envelope: resolved.envelope_name.is_some(),
|
||||
needs_conversion: matches!(
|
||||
resolved.conversion_mode,
|
||||
crate::ai_serving::ConversionMode::Bidirectional
|
||||
),
|
||||
extra_fields,
|
||||
}),
|
||||
resolved.execution_strategy,
|
||||
resolved.conversion_mode,
|
||||
"openai:chat",
|
||||
candidate.endpoint_api_format.as_str(),
|
||||
),
|
||||
&resolved.transport,
|
||||
);
|
||||
let super::request::LocalOpenAiChatCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
report_kind,
|
||||
envelope_name: _,
|
||||
transport,
|
||||
} = resolved;
|
||||
|
||||
Some(build_ai_execution_decision_response(
|
||||
AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: upstream_is_stream,
|
||||
decision_kind: decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: None,
|
||||
auth_header: Some(auth_header),
|
||||
auth_value: Some(auth_value),
|
||||
provider_api_format,
|
||||
client_api_format: "openai:chat".to_string(),
|
||||
model_name: input.requested_model.clone(),
|
||||
mapped_model,
|
||||
prompt_cache_key,
|
||||
provider_request_headers,
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy,
|
||||
tls_profile,
|
||||
timeouts,
|
||||
upstream_is_stream,
|
||||
report_kind: Some(report_kind),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth,
|
||||
OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::common::OPENAI_CHAT_STREAM_PLAN_KIND;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_headers, 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,
|
||||
};
|
||||
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
|
||||
KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::transport::local_openai_chat_transport_unsupported_reason;
|
||||
use crate::ai_serving::transport::{
|
||||
build_kiro_cross_format_upstream_url, build_standard_provider_request_headers,
|
||||
StandardProviderRequestHeadersInput,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
LocalResolvedOAuthRequestAuth,
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
use crate::AppState;
|
||||
|
||||
use super::support::{
|
||||
mark_skipped_local_openai_chat_candidate,
|
||||
mark_skipped_local_openai_chat_candidate_with_extra_data,
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic, LocalOpenAiChatDecisionInput,
|
||||
};
|
||||
|
||||
pub(crate) struct LocalOpenAiChatCandidatePayloadParts {
|
||||
pub(super) auth_header: String,
|
||||
pub(super) auth_value: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) provider_request_body: Value,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) execution_strategy: ExecutionStrategy,
|
||||
pub(super) conversion_mode: ConversionMode,
|
||||
pub(super) report_kind: String,
|
||||
pub(super) envelope_name: Option<&'static str>,
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
decision_kind: &str,
|
||||
report_kind: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<LocalOpenAiChatCandidatePayloadParts> {
|
||||
let planner_state = crate::ai_serving::PlannerAppState::new(state);
|
||||
let candidate = &eligible.candidate;
|
||||
let provider_api_format = eligible.provider_api_format.as_str();
|
||||
let transport = &eligible.transport;
|
||||
|
||||
if provider_api_format == "openai:chat" {
|
||||
if let Some(skip_reason) = local_openai_chat_transport_unsupported_reason(transport) {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let prepared_candidate = match prepare_header_authenticated_candidate(
|
||||
planner_state,
|
||||
transport,
|
||||
candidate,
|
||||
resolve_local_openai_bearer_auth(transport),
|
||||
OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: "openai:chat",
|
||||
operation: "openai_chat_same_format",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_request_body) = build_local_openai_chat_request_body(
|
||||
body_json,
|
||||
&prepared_candidate.mapped_model,
|
||||
upstream_is_stream,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
) 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 None;
|
||||
};
|
||||
|
||||
let Some(upstream_url) = build_local_openai_chat_upstream_url(parts, transport) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
"openai_chat_same_format_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport,
|
||||
provider_api_format,
|
||||
same_format: true,
|
||||
headers: &parts.headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
"openai_chat_same_format_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.endpoint.api_format.as_str(),
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats("openai:chat", "openai:chat");
|
||||
return Some(LocalOpenAiChatCandidatePayloadParts {
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
mapped_model: prepared_candidate.mapped_model,
|
||||
provider_api_format: "openai:chat".to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
report_kind: report_kind.to_string(),
|
||||
envelope_name: None,
|
||||
transport: Arc::clone(transport),
|
||||
});
|
||||
}
|
||||
|
||||
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
|
||||
let Some(conversion_kind) =
|
||||
request_conversion_kind("openai:chat", provider_api_format.as_str())
|
||||
else {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_api_format_unsupported",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
if let Some(skip_reason) = crate::ai_serving::request_conversion_transport_unsupported_reason(
|
||||
transport,
|
||||
conversion_kind,
|
||||
) {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
let is_kiro_claude_cli =
|
||||
is_kiro_claude_messages_transport(transport, provider_api_format.as_str());
|
||||
let oauth_context = OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: provider_api_format.as_str(),
|
||||
operation: "openai_chat_cross_format",
|
||||
};
|
||||
let kiro_auth = if is_kiro_claude_cli {
|
||||
match crate::ai_serving::planner::candidate_preparation::resolve_candidate_oauth_auth(
|
||||
planner_state,
|
||||
transport,
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
|
||||
_ => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
match prepare_header_authenticated_candidate_from_auth(
|
||||
candidate,
|
||||
kiro_auth.name.to_string(),
|
||||
kiro_auth.value.clone(),
|
||||
) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match prepare_header_authenticated_candidate(
|
||||
planner_state,
|
||||
transport,
|
||||
candidate,
|
||||
request_conversion_direct_auth(transport, conversion_kind),
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_chat_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_request_body) = build_cross_format_openai_chat_request_body(
|
||||
body_json,
|
||||
&prepared_candidate.mapped_model,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
upstream_is_stream,
|
||||
if is_kiro_claude_cli {
|
||||
None
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.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.as_str(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
return build_kiro_openai_chat_cross_format_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
decision_kind,
|
||||
transport,
|
||||
provider_api_format.as_str(),
|
||||
prepared_candidate.mapped_model,
|
||||
prepared_candidate.auth_header,
|
||||
prepared_candidate.auth_value,
|
||||
provider_request_body,
|
||||
upstream_is_stream,
|
||||
kiro_auth,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
&prepared_candidate.mapped_model,
|
||||
provider_api_format.as_str(),
|
||||
upstream_is_stream,
|
||||
) else {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
"openai:chat",
|
||||
provider_api_format.as_str(),
|
||||
"openai_chat_cross_format_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport,
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
same_format: false,
|
||||
headers: &parts.headers,
|
||||
auth_header: &prepared_candidate.auth_header,
|
||||
auth_value: &prepared_candidate.auth_value,
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
"openai:chat",
|
||||
provider_api_format.as_str(),
|
||||
"openai_chat_cross_format_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format.as_str(),
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
|
||||
let resolved_report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
|
||||
"openai_chat_stream_success".to_string()
|
||||
} else {
|
||||
"openai_chat_sync_finalize".to_string()
|
||||
};
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats("openai:chat", provider_api_format.as_str());
|
||||
|
||||
Some(LocalOpenAiChatCandidatePayloadParts {
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
mapped_model: prepared_candidate.mapped_model,
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
report_kind: resolved_report_kind,
|
||||
envelope_name: None,
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_kiro_openai_chat_cross_format_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
original_body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
decision_kind: &str,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
provider_api_format: &str,
|
||||
mapped_model: String,
|
||||
auth_header: String,
|
||||
auth_value: String,
|
||||
claude_request_body: Value,
|
||||
upstream_is_stream: bool,
|
||||
kiro_auth: &KiroRequestAuth,
|
||||
) -> Option<LocalOpenAiChatCandidatePayloadParts> {
|
||||
let candidate = &eligible.candidate;
|
||||
let provider_request_body = match build_kiro_provider_request_body(
|
||||
&claude_request_body,
|
||||
&mapped_model,
|
||||
&kiro_auth.auth_config,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
CandidateFailureDiagnostic::envelope_build_failed(
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
"openai_chat_kiro_envelope",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let upstream_url = match build_kiro_cross_format_upstream_url(
|
||||
transport,
|
||||
&mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
kiro_auth.auth_config.effective_api_region(),
|
||||
) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
"openai_chat_kiro_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &parts.headers,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: original_body_json,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
auth_config: &kiro_auth.auth_config,
|
||||
machine_id: kiro_auth.machine_id.as_str(),
|
||||
}) {
|
||||
Some(headers) => headers,
|
||||
None => {
|
||||
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
"openai:chat",
|
||||
provider_api_format,
|
||||
"openai_chat_kiro_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let resolved_report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
|
||||
"openai_chat_stream_success".to_string()
|
||||
} else {
|
||||
"openai_chat_sync_finalize".to_string()
|
||||
};
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats("openai:chat", provider_api_format);
|
||||
|
||||
Some(LocalOpenAiChatCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
report_kind: resolved_report_kind,
|
||||
envelope_name: Some(KIRO_ENVELOPE_NAME),
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_contract_metadata,
|
||||
build_local_execution_candidate_contract_metadata_for_candidate,
|
||||
LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::SkippedLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::CandidateFailureDiagnostic;
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
|
||||
ExecutionRuntimeAuthContext, PlannerAppState,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiChatCandidateAttempt;
|
||||
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiChatDecisionInput;
|
||||
|
||||
pub(crate) async fn mark_skipped_local_openai_chat_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiChatDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn mark_skipped_local_openai_chat_candidate_with_extra_data(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
extra_data: Option<serde_json::Value>,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiChatDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_extra_data(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
extra_data,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiChatDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
|
||||
) -> Vec<LocalOpenAiChatCandidateAttempt> {
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiChatDecision,
|
||||
);
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
"openai:chat",
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.required_capabilities.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
persistence_policy,
|
||||
candidates,
|
||||
preselection_skipped,
|
||||
LocalCandidateResolutionMode::Standard,
|
||||
|eligible| {
|
||||
let provider_api_format = eligible.provider_api_format.clone();
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats("openai:chat", &provider_api_format);
|
||||
Some(build_local_execution_candidate_contract_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
client_api_format: "openai:chat",
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
eligible.candidate.endpoint_api_format.trim(),
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
skipped_candidate
|
||||
.candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats("openai:chat", &provider_api_format);
|
||||
skipped_candidate.extra_data = Some(
|
||||
build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
"openai:chat",
|
||||
serde_json::Map::new(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
);
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
outcome.attempts
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::common::{
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::set_local_runtime_execution_exhausted_diagnostic;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
mod decision;
|
||||
mod plans;
|
||||
|
||||
use self::decision::{
|
||||
materialize_local_openai_chat_candidate_attempts,
|
||||
maybe_build_local_openai_chat_decision_payload_for_candidate, LocalOpenAiChatDecisionInput,
|
||||
};
|
||||
use self::plans::{
|
||||
build_local_openai_chat_stream_plan_and_reports, build_local_openai_chat_sync_plan_and_reports,
|
||||
list_local_openai_chat_candidates, resolve_local_openai_chat_decision_input,
|
||||
set_local_openai_chat_miss_diagnostic,
|
||||
};
|
||||
|
||||
pub(crate) async fn build_local_openai_chat_sync_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<crate::ai_serving::planner::plan_builders::AiSyncAttempt>, GatewayError> {
|
||||
build_local_openai_chat_sync_plan_and_reports(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_openai_chat_stream_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<crate::ai_serving::planner::plan_builders::AiStreamAttempt>, GatewayError> {
|
||||
build_local_openai_chat_stream_plan_and_reports(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_openai_chat_execution_exhausted_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
body_json: &serde_json::Value,
|
||||
plan_count: usize,
|
||||
) {
|
||||
warn!(
|
||||
event_name = "local_openai_chat_candidates_exhausted",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
plan_kind,
|
||||
route_class = decision.route_class.as_deref().unwrap_or("passthrough"),
|
||||
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
|
||||
candidate_count = plan_count,
|
||||
model = body_json.get("model").and_then(|value| value.as_str()).unwrap_or(""),
|
||||
"gateway local openai chat execution exhausted all candidates"
|
||||
);
|
||||
set_local_runtime_execution_exhausted_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
body_json.get("model").and_then(|value| value.as_str()),
|
||||
plan_count,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if plan_kind != OPENAI_CHAT_SYNC_PLAN_KIND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(input) = resolve_local_openai_chat_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind, false,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (candidates, skipped_candidates) =
|
||||
match list_local_openai_chat_candidates(state, &input, false).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "local_openai_chat_scheduler_selection_failed",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat sync decision scheduler selection failed"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let attempts = materialize_local_openai_chat_candidate_attempts(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
candidates,
|
||||
skipped_candidates,
|
||||
)
|
||||
.await;
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
&input,
|
||||
attempt,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
"openai_chat_sync_success",
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
if plan_kind != OPENAI_CHAT_STREAM_PLAN_KIND {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(input) = resolve_local_openai_chat_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind, false,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (candidates, skipped_candidates) =
|
||||
match list_local_openai_chat_candidates(state, &input, true).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "local_openai_chat_scheduler_selection_failed",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat stream decision scheduler selection failed"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let attempts = materialize_local_openai_chat_candidate_attempts(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
candidates,
|
||||
skipped_candidates,
|
||||
)
|
||||
.await;
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
&input,
|
||||
attempt,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"openai_chat_stream_success",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#[path = "plans/candidates.rs"]
|
||||
mod candidates;
|
||||
#[path = "plans/diagnostic.rs"]
|
||||
mod diagnostic;
|
||||
#[path = "plans/resolve.rs"]
|
||||
mod resolve;
|
||||
#[path = "plans/stream.rs"]
|
||||
mod stream;
|
||||
#[path = "plans/sync.rs"]
|
||||
mod sync;
|
||||
|
||||
pub(super) use self::candidates::list_local_openai_chat_candidates;
|
||||
pub(super) use self::diagnostic::set_local_openai_chat_miss_diagnostic;
|
||||
pub(super) use self::resolve::resolve_local_openai_chat_decision_input;
|
||||
pub(super) use self::stream::build_local_openai_chat_stream_plan_and_reports;
|
||||
pub(super) use self::sync::build_local_openai_chat_sync_plan_and_reports;
|
||||
@@ -0,0 +1,35 @@
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
use super::super::{GatewayError, LocalOpenAiChatDecisionInput};
|
||||
use crate::ai_serving::planner::candidate_resolution::SkippedLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::candidate_source::{
|
||||
preselect_local_execution_candidates_with_serving, LocalCandidatePreselectionKeyMode,
|
||||
};
|
||||
use crate::ai_serving::PlannerAppState;
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) async fn list_local_openai_chat_candidates(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiChatDecisionInput,
|
||||
require_streaming: bool,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
Vec<SkippedLocalExecutionCandidate>,
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
let outcome = preselect_local_execution_candidates_with_serving(
|
||||
PlannerAppState::new(state),
|
||||
"openai:chat",
|
||||
&input.requested_model,
|
||||
require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
&input.auth_snapshot,
|
||||
false,
|
||||
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((outcome.candidates, outcome.skipped_candidates))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::super::GatewayControlDecision;
|
||||
use crate::ai_serving::planner::runtime_miss::{
|
||||
set_local_runtime_candidate_evaluation_diagnostic, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn set_local_openai_chat_miss_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
reason: &str,
|
||||
) {
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_local_openai_chat_candidate_evaluation_diagnostic(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
requested_model: Option<&str>,
|
||||
candidate_count: usize,
|
||||
) {
|
||||
set_local_runtime_candidate_evaluation_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
requested_model,
|
||||
candidate_count,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use tracing::warn;
|
||||
|
||||
use super::super::{GatewayControlDecision, LocalOpenAiChatDecisionInput};
|
||||
use super::diagnostic::set_local_openai_chat_miss_diagnostic;
|
||||
use crate::ai_serving::planner::common::extract_standard_requested_model;
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::resolve_local_decision_execution_runtime_auth_context;
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) async fn resolve_local_openai_chat_decision_input(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
record_miss_diagnostic: bool,
|
||||
) -> Option<LocalOpenAiChatDecisionInput> {
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
route_class = ?decision.route_class,
|
||||
route_family = ?decision.route_family,
|
||||
route_kind = ?decision.route_kind,
|
||||
"gateway local openai chat decision skipped: missing_auth_context"
|
||||
);
|
||||
if record_miss_diagnostic {
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
extract_standard_requested_model(body_json).as_deref(),
|
||||
"missing_auth_context",
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(requested_model) = extract_standard_requested_model(body_json) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
"gateway local openai chat decision skipped: missing_requested_model"
|
||||
);
|
||||
if record_miss_diagnostic {
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
None,
|
||||
"missing_requested_model",
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context.clone(),
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
user_id = %auth_context.user_id,
|
||||
api_key_id = %auth_context.api_key_id,
|
||||
"gateway local openai chat decision skipped: auth_snapshot_missing"
|
||||
);
|
||||
if record_miss_diagnostic {
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_missing",
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat decision auth snapshot read failed"
|
||||
);
|
||||
if record_miss_diagnostic {
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_read_failed",
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(build_local_requested_model_decision_input(
|
||||
resolved_input,
|
||||
requested_model,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use tracing::warn;
|
||||
|
||||
use super::super::{
|
||||
materialize_local_openai_chat_candidate_attempts,
|
||||
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
|
||||
GatewayError,
|
||||
};
|
||||
use super::candidates::list_local_openai_chat_candidates;
|
||||
use super::diagnostic::{
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic, set_local_openai_chat_miss_diagnostic,
|
||||
};
|
||||
use super::resolve::resolve_local_openai_chat_decision_input;
|
||||
use crate::ai_serving::planner::common::OPENAI_CHAT_STREAM_PLAN_KIND;
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_openai_chat_stream_plan_from_decision, AiStreamAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::apply_local_runtime_candidate_terminal_reason;
|
||||
|
||||
pub(crate) async fn build_local_openai_chat_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
if plan_kind != OPENAI_CHAT_STREAM_PLAN_KIND {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let Some(input) = resolve_local_openai_chat_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind, true,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (candidates, skipped_candidates) =
|
||||
match list_local_openai_chat_candidates(state, &input, true).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat stream decision scheduler selection failed"
|
||||
);
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"scheduler_selection_failed",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
if candidates.is_empty() && skipped_candidates.is_empty() {
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
0,
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
candidates.len() + skipped_candidates.len(),
|
||||
);
|
||||
|
||||
let attempts = materialize_local_openai_chat_candidate_attempts(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
candidates,
|
||||
skipped_candidates,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
&input,
|
||||
attempt,
|
||||
OPENAI_CHAT_STREAM_PLAN_KIND,
|
||||
"openai_chat_stream_success",
|
||||
true,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_openai_chat_stream_plan_from_decision(parts, body_json, payload) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat stream decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use tracing::warn;
|
||||
|
||||
use super::super::{
|
||||
materialize_local_openai_chat_candidate_attempts,
|
||||
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
|
||||
GatewayError,
|
||||
};
|
||||
use super::candidates::list_local_openai_chat_candidates;
|
||||
use super::diagnostic::{
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic, set_local_openai_chat_miss_diagnostic,
|
||||
};
|
||||
use super::resolve::resolve_local_openai_chat_decision_input;
|
||||
use crate::ai_serving::planner::common::{
|
||||
force_upstream_streaming_for_provider, OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_openai_chat_sync_plan_from_decision, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::apply_local_runtime_candidate_terminal_reason;
|
||||
|
||||
fn openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
) -> bool {
|
||||
force_upstream_streaming_for_provider(provider_type, provider_api_format)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
if plan_kind != OPENAI_CHAT_SYNC_PLAN_KIND {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let Some(input) = resolve_local_openai_chat_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind, true,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (candidates, skipped_candidates) =
|
||||
match list_local_openai_chat_candidates(state, &input, false).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat sync decision scheduler selection failed"
|
||||
);
|
||||
set_local_openai_chat_miss_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"scheduler_selection_failed",
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
if candidates.is_empty() && skipped_candidates.is_empty() {
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
0,
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
set_local_openai_chat_candidate_evaluation_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
candidates.len() + skipped_candidates.len(),
|
||||
);
|
||||
|
||||
let attempts = materialize_local_openai_chat_candidate_attempts(
|
||||
state,
|
||||
trace_id,
|
||||
&input,
|
||||
body_json,
|
||||
candidates,
|
||||
skipped_candidates,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let upstream_is_stream = openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
attempt.eligible.transport.provider.provider_type.as_str(),
|
||||
attempt.eligible.provider_api_format.as_str(),
|
||||
);
|
||||
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
&input,
|
||||
attempt,
|
||||
OPENAI_CHAT_SYNC_PLAN_KIND,
|
||||
"openai_chat_sync_success",
|
||||
upstream_is_stream,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_openai_chat_sync_plan_from_decision(parts, body_json, payload) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai chat sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::openai_chat_sync_upstream_is_stream_for_candidate;
|
||||
|
||||
#[test]
|
||||
fn openai_chat_sync_forces_streaming_for_codex_openai_responses_candidates() {
|
||||
assert!(openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
"codex",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
"codex",
|
||||
"openai:responses:compact"
|
||||
));
|
||||
assert!(!openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
"openai",
|
||||
"openai:responses"
|
||||
));
|
||||
assert!(!openai_chat_sync_upstream_is_stream_for_candidate(
|
||||
"codex",
|
||||
"openai:chat"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
mod chat;
|
||||
mod responses;
|
||||
|
||||
pub(crate) use crate::ai_serving::{
|
||||
copy_request_number_field, copy_request_number_field_as,
|
||||
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
|
||||
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
|
||||
};
|
||||
pub(crate) use chat::{
|
||||
build_local_openai_chat_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_chat_sync_plan_and_reports_for_kind,
|
||||
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
|
||||
set_local_openai_chat_execution_exhausted_diagnostic,
|
||||
};
|
||||
pub(crate) use responses::{
|
||||
build_local_openai_responses_stream_plan_and_reports_for_kind,
|
||||
build_local_openai_responses_sync_plan_and_reports_for_kind,
|
||||
maybe_build_stream_local_openai_responses_decision_payload,
|
||||
maybe_build_sync_local_openai_responses_decision_payload,
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#[path = "plan_builders/stream.rs"]
|
||||
mod stream;
|
||||
#[path = "plan_builders/sync.rs"]
|
||||
mod sync;
|
||||
|
||||
pub(crate) use self::stream::{
|
||||
build_openai_chat_stream_plan_from_decision, build_openai_responses_stream_plan_from_decision,
|
||||
};
|
||||
pub(crate) use self::sync::{
|
||||
build_openai_chat_sync_plan_from_decision, build_openai_responses_sync_plan_from_decision,
|
||||
};
|
||||
@@ -0,0 +1,504 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::RequestBody;
|
||||
use tracing::debug;
|
||||
|
||||
use super::super::{
|
||||
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||
generic_decision_missing_exact_provider_request, take_ai_decision_plan_core,
|
||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
||||
AiStreamAttempt,
|
||||
};
|
||||
use crate::ai_serving::provider_adaptation_requires_eventstream_accept;
|
||||
use crate::ai_serving::transport::{
|
||||
build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url,
|
||||
build_standard_plan_fallback_openai_responses_url, StandardPlanFallbackAcceptPolicy,
|
||||
StandardPlanFallbackHeadersInput,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let url = if let Some(upstream_url) = take_non_empty_string(&mut payload.upstream_url) {
|
||||
upstream_url
|
||||
} else {
|
||||
let Some(upstream_base_url) = take_non_empty_string(&mut payload.upstream_base_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
build_standard_plan_fallback_openai_chat_url(&upstream_base_url, parts.uri.query())
|
||||
};
|
||||
let provider_request_body_value = if let Some(body) = payload.provider_request_body.take() {
|
||||
body
|
||||
} else {
|
||||
let Some(request_body_object) = body_json.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
if let Some(mapped_model) = take_non_empty_string(&mut payload.mapped_model) {
|
||||
provider_request_body
|
||||
.insert("model".to_string(), serde_json::Value::String(mapped_model));
|
||||
}
|
||||
provider_request_body.insert("stream".to_string(), serde_json::Value::Bool(true));
|
||||
if let Some(prompt_cache_key) = take_non_empty_string(&mut payload.prompt_cache_key) {
|
||||
let existing = provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if existing.is_empty() {
|
||||
provider_request_body.insert(
|
||||
"prompt_cache_key".to_string(),
|
||||
serde_json::Value::String(prompt_cache_key),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(provider_request_body)
|
||||
};
|
||||
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &extra_headers,
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: true,
|
||||
build_from_request_when_empty: true,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamRequired,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiStreamAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_responses_stream_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
compact: bool,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (url, url_source) = if let Some(upstream_url) =
|
||||
take_non_empty_string(&mut payload.upstream_url)
|
||||
{
|
||||
(upstream_url, "upstream_url")
|
||||
} else {
|
||||
let Some(upstream_base_url) = take_non_empty_string(&mut payload.upstream_base_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
build_standard_plan_fallback_openai_responses_url(
|
||||
&upstream_base_url,
|
||||
parts.uri.query(),
|
||||
compact,
|
||||
),
|
||||
"upstream_base_url",
|
||||
)
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let envelope_name = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("envelope_name"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let accept_policy = if provider_adaptation_requires_eventstream_accept(
|
||||
envelope_name,
|
||||
core.provider_api_format.as_str(),
|
||||
) {
|
||||
StandardPlanFallbackAcceptPolicy::ProviderEventStreamIfMissing
|
||||
} else {
|
||||
StandardPlanFallbackAcceptPolicy::TextEventStreamRequired
|
||||
};
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: true,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
|
||||
debug!(
|
||||
event_name = "local_openai_responses_stream_plan_built",
|
||||
log_type = "debug",
|
||||
request_id = %plan.request_id,
|
||||
candidate_id = ?plan.candidate_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
downstream_path = %parts.uri.path(),
|
||||
downstream_query = ?parts.uri.query(),
|
||||
url_source,
|
||||
decision_upstream_base_url = ?payload.upstream_base_url,
|
||||
decision_upstream_url = ?payload.upstream_url,
|
||||
plan_url = %plan.url,
|
||||
client_api_format = %plan.client_api_format,
|
||||
provider_api_format = %plan.provider_api_format,
|
||||
upstream_is_stream = payload.upstream_is_stream,
|
||||
compact,
|
||||
"gateway built local openai responses stream execution plan"
|
||||
);
|
||||
|
||||
Ok(Some(AiStreamAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_stream_plan_from_decision,
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
};
|
||||
use crate::AiExecutionDecision;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_responses_payload() -> AiExecutionDecision {
|
||||
AiExecutionDecision {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_responses_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: 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()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:responses".to_string()),
|
||||
client_api_format: Some("openai:responses".to_string()),
|
||||
provider_contract: Some("openai:responses".to_string()),
|
||||
client_contract: Some("openai:responses".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_responses_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_responses_stream_plan_preserves_provider_request_body_order_in_plan_and_report()
|
||||
{
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_responses_payload();
|
||||
|
||||
let built =
|
||||
build_openai_responses_stream_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.is_none(),
|
||||
"report context should not duplicate provider request body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = AiExecutionDecision {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_stream_456".to_string()),
|
||||
candidate_id: Some("cand_stream_456".to_string()),
|
||||
provider_name: 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()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":true})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = AiExecutionDecision {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_stream_789".to_string()),
|
||||
candidate_id: Some("cand_stream_789".to_string()),
|
||||
provider_name: Some("Claude".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()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:messages".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:messages".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":true}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::RequestBody;
|
||||
use tracing::debug;
|
||||
|
||||
use super::super::{
|
||||
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||
generic_decision_missing_exact_provider_request, take_ai_decision_plan_core,
|
||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
||||
AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_standard_plan_fallback_headers, build_standard_plan_fallback_openai_chat_url,
|
||||
build_standard_plan_fallback_openai_responses_url, StandardPlanFallbackAcceptPolicy,
|
||||
StandardPlanFallbackHeadersInput,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let url = if let Some(upstream_url) = take_non_empty_string(&mut payload.upstream_url) {
|
||||
upstream_url
|
||||
} else {
|
||||
let Some(upstream_base_url) = take_non_empty_string(&mut payload.upstream_base_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
build_standard_plan_fallback_openai_chat_url(&upstream_base_url, parts.uri.query())
|
||||
};
|
||||
let provider_request_body_value = if let Some(body) = payload.provider_request_body.take() {
|
||||
body
|
||||
} else {
|
||||
let Some(request_body_object) = body_json.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
if let Some(mapped_model) = take_non_empty_string(&mut payload.mapped_model) {
|
||||
provider_request_body
|
||||
.insert("model".to_string(), serde_json::Value::String(mapped_model));
|
||||
}
|
||||
if payload.upstream_is_stream {
|
||||
provider_request_body.insert("stream".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
if let Some(prompt_cache_key) = take_non_empty_string(&mut payload.prompt_cache_key) {
|
||||
let existing = provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if existing.is_empty() {
|
||||
provider_request_body.insert(
|
||||
"prompt_cache_key".to_string(),
|
||||
serde_json::Value::String(prompt_cache_key),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(provider_request_body)
|
||||
};
|
||||
let extra_headers = std::mem::take(&mut payload.extra_headers);
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &extra_headers,
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: payload.upstream_is_stream,
|
||||
build_from_request_when_empty: true,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamIfStreaming,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiSyncAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_responses_sync_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
compact: bool,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (url, url_source) = if let Some(upstream_url) =
|
||||
take_non_empty_string(&mut payload.upstream_url)
|
||||
{
|
||||
(upstream_url, "upstream_url")
|
||||
} else {
|
||||
let Some(upstream_base_url) = take_non_empty_string(&mut payload.upstream_base_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
(
|
||||
build_standard_plan_fallback_openai_responses_url(
|
||||
&upstream_base_url,
|
||||
parts.uri.query(),
|
||||
compact,
|
||||
),
|
||||
"upstream_base_url",
|
||||
)
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: payload.upstream_is_stream,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamIfStreaming,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
debug!(
|
||||
event_name = "local_openai_responses_sync_plan_built",
|
||||
log_type = "debug",
|
||||
request_id = %plan.request_id,
|
||||
candidate_id = ?plan.candidate_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
downstream_path = %parts.uri.path(),
|
||||
downstream_query = ?parts.uri.query(),
|
||||
url_source,
|
||||
decision_upstream_base_url = ?payload.upstream_base_url,
|
||||
decision_upstream_url = ?payload.upstream_url,
|
||||
plan_url = %plan.url,
|
||||
client_api_format = %plan.client_api_format,
|
||||
provider_api_format = %plan.provider_api_format,
|
||||
upstream_is_stream = payload.upstream_is_stream,
|
||||
compact,
|
||||
"gateway built local openai responses sync execution plan"
|
||||
);
|
||||
|
||||
Ok(Some(AiSyncAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_sync_plan_from_decision, build_openai_responses_sync_plan_from_decision,
|
||||
};
|
||||
use crate::AiExecutionDecision;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_responses_payload() -> AiExecutionDecision {
|
||||
AiExecutionDecision {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_responses_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: 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()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:responses".to_string()),
|
||||
client_api_format: Some("openai:responses".to_string()),
|
||||
provider_contract: Some("openai:responses".to_string()),
|
||||
client_contract: Some("openai:responses".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_responses_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_responses_sync_plan_preserves_provider_request_body_order_in_plan_and_report() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_responses_payload();
|
||||
|
||||
let built =
|
||||
build_openai_responses_sync_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.is_none(),
|
||||
"report context should not duplicate provider request body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = AiExecutionDecision {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_456".to_string()),
|
||||
candidate_id: Some("cand_456".to_string()),
|
||||
provider_name: 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()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":false})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = AiExecutionDecision {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_789".to_string()),
|
||||
candidate_id: Some("cand_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_id: Some("prov_789".to_string()),
|
||||
endpoint_id: Some("ep_789".to_string()),
|
||||
key_id: Some("key_789".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:messages".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:messages".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":false}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#[path = "decision/payload.rs"]
|
||||
mod payload;
|
||||
#[path = "decision/request.rs"]
|
||||
mod request;
|
||||
#[path = "decision/support.rs"]
|
||||
mod support;
|
||||
|
||||
pub(super) use self::payload::maybe_build_local_openai_responses_decision_payload_for_candidate;
|
||||
pub(super) use self::support::{
|
||||
materialize_local_openai_responses_candidate_attempts,
|
||||
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesCandidateAttempt,
|
||||
LocalOpenAiResponsesDecisionInput,
|
||||
};
|
||||
pub(super) use crate::ai_serving::LocalOpenAiResponsesSpec;
|
||||
@@ -0,0 +1,205 @@
|
||||
use serde_json::json;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ai_serving::build_request_trace_proxy_value;
|
||||
use crate::ai_serving::planner::report_context::{
|
||||
build_local_execution_report_context, insert_provider_stream_event_api_format,
|
||||
LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value,
|
||||
AiExecutionDecision, AppState,
|
||||
};
|
||||
|
||||
use super::request::resolve_local_openai_responses_candidate_payload_parts;
|
||||
use super::support::{LocalOpenAiResponsesCandidateAttempt, LocalOpenAiResponsesDecisionInput};
|
||||
use super::LocalOpenAiResponsesSpec;
|
||||
|
||||
pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_candidate(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
attempt: LocalOpenAiResponsesCandidateAttempt,
|
||||
spec: LocalOpenAiResponsesSpec,
|
||||
) -> Option<AiExecutionDecision> {
|
||||
let spec_metadata = local_openai_responses_spec_metadata(spec);
|
||||
let attempt_identity = attempt.attempt_identity();
|
||||
let LocalOpenAiResponsesCandidateAttempt {
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
..
|
||||
} = attempt;
|
||||
let resolved = resolve_local_openai_responses_candidate_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
&eligible,
|
||||
candidate_index,
|
||||
&candidate_id,
|
||||
spec,
|
||||
)
|
||||
.await?;
|
||||
let candidate = &eligible.candidate;
|
||||
|
||||
let prompt_cache_key = resolved
|
||||
.provider_request_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if let Some(proxy_value) =
|
||||
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||
{
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
if let Some(envelope_name) = resolved.envelope_name {
|
||||
extra_fields.insert("envelope_name".to_string(), json!(envelope_name));
|
||||
}
|
||||
insert_provider_stream_event_api_format(
|
||||
&mut extra_fields,
|
||||
resolved.transport.provider.provider_type.as_str(),
|
||||
);
|
||||
let report_context = append_local_failover_policy_to_value(
|
||||
append_execution_contract_fields_to_value(
|
||||
build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
request_id: trace_id,
|
||||
candidate_id: &candidate_id,
|
||||
attempt_identity,
|
||||
model: &input.requested_model,
|
||||
provider_name: &resolved.transport.provider.name,
|
||||
provider_id: &candidate.provider_id,
|
||||
endpoint_id: &candidate.endpoint_id,
|
||||
key_id: &candidate.key_id,
|
||||
key_name: Some(&candidate.key_name),
|
||||
model_id: Some(&candidate.model_id),
|
||||
global_model_id: Some(&candidate.global_model_id),
|
||||
global_model_name: Some(&candidate.global_model_name),
|
||||
provider_api_format: &resolved.provider_api_format,
|
||||
client_api_format: spec_metadata.api_format,
|
||||
mapped_model: Some(&resolved.mapped_model),
|
||||
candidate_group_id: eligible.orchestration.candidate_group_id.as_deref(),
|
||||
ranking: eligible.ranking.as_ref(),
|
||||
upstream_url: Some(&resolved.upstream_url),
|
||||
header_rules: resolved.transport.endpoint.header_rules.as_ref(),
|
||||
body_rules: resolved.transport.endpoint.body_rules.as_ref(),
|
||||
provider_request_method: Some(serde_json::Value::Null),
|
||||
provider_request_headers: Some(&resolved.provider_request_headers),
|
||||
original_headers: &parts.headers,
|
||||
request_origin: Some(crate::ai_serving::request_origin_from_parts(parts)),
|
||||
original_request_body_json: Some(body_json),
|
||||
original_request_body_base64: None,
|
||||
client_requested_stream: body_json
|
||||
.get("stream")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
upstream_is_stream: resolved.upstream_is_stream,
|
||||
has_envelope: resolved.envelope_name.is_some(),
|
||||
needs_conversion: matches!(
|
||||
resolved.conversion_mode,
|
||||
crate::ai_serving::ConversionMode::Bidirectional
|
||||
),
|
||||
extra_fields,
|
||||
}),
|
||||
resolved.execution_strategy,
|
||||
resolved.conversion_mode,
|
||||
spec_metadata.api_format,
|
||||
candidate.endpoint_api_format.as_str(),
|
||||
),
|
||||
&resolved.transport,
|
||||
);
|
||||
|
||||
debug!(
|
||||
event_name = "local_openai_responses_decision_payload_built",
|
||||
log_type = "debug",
|
||||
trace_id = %trace_id,
|
||||
candidate_id = %candidate_id,
|
||||
candidate_index,
|
||||
provider_name = %resolved.transport.provider.name,
|
||||
provider_id = %candidate.provider_id,
|
||||
endpoint_id = %candidate.endpoint_id,
|
||||
key_id = %candidate.key_id,
|
||||
decision_kind = spec_metadata.decision_kind,
|
||||
execution_strategy = resolved.execution_strategy.as_str(),
|
||||
conversion_mode = resolved.conversion_mode.as_str(),
|
||||
client_api_format = spec_metadata.api_format,
|
||||
provider_api_format = %resolved.provider_api_format,
|
||||
request_path = %parts.uri.path(),
|
||||
request_query = ?parts.uri.query(),
|
||||
upstream_base_url = %resolved.transport.endpoint.base_url,
|
||||
upstream_url = %resolved.upstream_url,
|
||||
upstream_is_stream = resolved.upstream_is_stream,
|
||||
has_envelope = resolved.envelope_name.is_some(),
|
||||
"gateway built local openai responses decision payload"
|
||||
);
|
||||
let super::request::LocalOpenAiResponsesCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
is_antigravity: _,
|
||||
envelope_name: _,
|
||||
upstream_is_stream,
|
||||
transport,
|
||||
} = resolved;
|
||||
|
||||
Some(build_ai_execution_decision_response(
|
||||
AiExecutionDecisionResponseParts {
|
||||
decision_is_stream: spec_metadata.require_streaming,
|
||||
decision_kind: spec_metadata.decision_kind.to_string(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: candidate_id.clone(),
|
||||
provider_name: transport.provider.name.clone(),
|
||||
provider_id: candidate.provider_id.clone(),
|
||||
endpoint_id: candidate.endpoint_id.clone(),
|
||||
key_id: candidate.key_id.clone(),
|
||||
upstream_base_url: transport.endpoint.base_url.clone(),
|
||||
upstream_url,
|
||||
provider_request_method: None,
|
||||
auth_header: Some(auth_header),
|
||||
auth_value: Some(auth_value),
|
||||
provider_api_format,
|
||||
client_api_format: spec_metadata.api_format.to_string(),
|
||||
model_name: input.requested_model.clone(),
|
||||
mapped_model,
|
||||
prompt_cache_key,
|
||||
provider_request_headers,
|
||||
provider_request_body: Some(provider_request_body),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy,
|
||||
tls_profile,
|
||||
timeouts,
|
||||
upstream_is_stream,
|
||||
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
|
||||
report_context: Some(report_context),
|
||||
auth_context: input.auth_context.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth,
|
||||
OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
|
||||
use crate::ai_serving::planner::common::force_upstream_streaming_for_provider;
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::planner::standard::{
|
||||
apply_codex_openai_responses_special_headers, 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,
|
||||
};
|
||||
use crate::ai_serving::transport::antigravity::{
|
||||
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
|
||||
classify_local_antigravity_request_support, is_antigravity_provider_transport,
|
||||
AntigravityEnvelopeRequestType, AntigravityRequestEnvelopeSupport,
|
||||
AntigravityRequestSideSupport,
|
||||
};
|
||||
use crate::ai_serving::transport::auth::{
|
||||
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
|
||||
};
|
||||
use crate::ai_serving::transport::kiro::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
is_kiro_claude_messages_transport,
|
||||
local_kiro_request_transport_unsupported_reason_with_network, KiroProviderHeadersInput,
|
||||
KiroRequestAuth, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_kiro_cross_format_upstream_url, build_standard_provider_request_headers,
|
||||
local_standard_transport_unsupported_reason_with_network, StandardProviderRequestHeadersInput,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
|
||||
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
LocalResolvedOAuthRequestAuth, PlannerAppState,
|
||||
};
|
||||
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
|
||||
use crate::AppState;
|
||||
|
||||
use super::support::{
|
||||
mark_skipped_local_openai_responses_candidate,
|
||||
mark_skipped_local_openai_responses_candidate_with_extra_data,
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic,
|
||||
LocalOpenAiResponsesDecisionInput,
|
||||
};
|
||||
use super::LocalOpenAiResponsesSpec;
|
||||
|
||||
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
|
||||
pub(crate) struct LocalOpenAiResponsesCandidatePayloadParts {
|
||||
pub(super) auth_header: String,
|
||||
pub(super) auth_value: String,
|
||||
pub(super) mapped_model: String,
|
||||
pub(super) provider_api_format: String,
|
||||
pub(super) provider_request_body: Value,
|
||||
pub(super) provider_request_headers: BTreeMap<String, String>,
|
||||
pub(super) upstream_url: String,
|
||||
pub(super) execution_strategy: ExecutionStrategy,
|
||||
pub(super) conversion_mode: ConversionMode,
|
||||
pub(super) is_antigravity: bool,
|
||||
pub(super) envelope_name: Option<&'static str>,
|
||||
pub(super) upstream_is_stream: bool,
|
||||
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
spec: LocalOpenAiResponsesSpec,
|
||||
) -> Option<LocalOpenAiResponsesCandidatePayloadParts> {
|
||||
let spec_metadata = local_openai_responses_spec_metadata(spec);
|
||||
let client_api_format = spec_metadata.api_format.trim().to_ascii_lowercase();
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let candidate = &eligible.candidate;
|
||||
let provider_api_format = eligible.provider_api_format.as_str();
|
||||
let transport = &eligible.transport;
|
||||
let is_antigravity = is_antigravity_provider_transport(transport);
|
||||
let is_kiro_claude_cli = is_kiro_claude_messages_transport(transport, provider_api_format);
|
||||
|
||||
let same_format = api_format_alias_matches(provider_api_format, &client_api_format);
|
||||
let conversion_kind = request_conversion_kind(spec_metadata.api_format, provider_api_format);
|
||||
let transport_unsupported_reason = if same_format && is_kiro_claude_cli {
|
||||
local_kiro_request_transport_unsupported_reason_with_network(transport)
|
||||
} else if same_format {
|
||||
local_standard_transport_unsupported_reason_with_network(transport, provider_api_format)
|
||||
} else {
|
||||
match conversion_kind {
|
||||
Some(_) if is_antigravity && provider_api_format == "gemini:generate_content" => None,
|
||||
Some(kind) => {
|
||||
crate::ai_serving::request_conversion_transport_unsupported_reason(transport, kind)
|
||||
}
|
||||
None => Some("transport_api_format_unsupported"),
|
||||
}
|
||||
};
|
||||
if let Some(skip_reason) = transport_unsupported_reason {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
|
||||
let oauth_context = OauthPreparationContext {
|
||||
trace_id,
|
||||
api_format: provider_api_format,
|
||||
operation: "openai_responses_candidate_request",
|
||||
};
|
||||
let kiro_auth = if is_kiro_claude_cli {
|
||||
match crate::ai_serving::planner::candidate_preparation::resolve_candidate_oauth_auth(
|
||||
planner_state,
|
||||
transport,
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(LocalResolvedOAuthRequestAuth::Kiro(auth)) => Some(auth),
|
||||
_ => {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let direct_auth = if kiro_auth.is_some() {
|
||||
None
|
||||
} else if same_format {
|
||||
match crate::ai_serving::normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"gemini:generate_content" => resolve_local_gemini_auth(transport),
|
||||
"claude:messages" => resolve_local_standard_auth(transport),
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
resolve_local_openai_bearer_auth(transport)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
conversion_kind.and_then(|kind| request_conversion_direct_auth(transport, kind))
|
||||
};
|
||||
let prepared_candidate = if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
match prepare_header_authenticated_candidate_from_auth(
|
||||
candidate,
|
||||
kiro_auth.name.to_string(),
|
||||
kiro_auth.value.clone(),
|
||||
) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match prepare_header_authenticated_candidate(
|
||||
planner_state,
|
||||
transport,
|
||||
candidate,
|
||||
direct_auth,
|
||||
oauth_context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let auth_header = prepared_candidate.auth_header;
|
||||
let auth_value = prepared_candidate.auth_value;
|
||||
let mapped_model = prepared_candidate.mapped_model;
|
||||
|
||||
let needs_bidirectional_conversion = !same_format && conversion_kind.is_some();
|
||||
let upstream_is_stream = spec_metadata.require_streaming
|
||||
|| is_antigravity
|
||||
|| force_upstream_streaming_for_provider(
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
);
|
||||
let Some(base_provider_request_body) = (if needs_bidirectional_conversion {
|
||||
build_cross_format_openai_responses_request_body(
|
||||
body_json,
|
||||
&mapped_model,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
transport.provider.provider_type.as_str(),
|
||||
if is_kiro_claude_cli {
|
||||
None
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
)
|
||||
} else {
|
||||
build_local_openai_responses_request_body(
|
||||
body_json,
|
||||
&mapped_model,
|
||||
upstream_is_stream,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
if is_kiro_claude_cli {
|
||||
None
|
||||
} else {
|
||||
transport.endpoint.body_rules.as_ref()
|
||||
},
|
||||
Some(input.auth_context.api_key_id.as_str()),
|
||||
)
|
||||
}) else {
|
||||
mark_skipped_local_openai_responses_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,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let antigravity_auth = if is_antigravity {
|
||||
match classify_local_antigravity_request_support(
|
||||
transport,
|
||||
&base_provider_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
|
||||
AntigravityRequestSideSupport::Unsupported(_) => {
|
||||
mark_skipped_local_openai_responses_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_unsupported",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
|
||||
match build_antigravity_safe_v1internal_request(
|
||||
antigravity_auth,
|
||||
trace_id,
|
||||
&mapped_model,
|
||||
&base_provider_request_body,
|
||||
AntigravityEnvelopeRequestType::Agent,
|
||||
) {
|
||||
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
|
||||
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
CandidateFailureDiagnostic::envelope_build_failed(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_antigravity_envelope",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
base_provider_request_body
|
||||
};
|
||||
|
||||
if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
return build_kiro_openai_responses_payload_parts(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
body_json,
|
||||
input,
|
||||
eligible,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
spec_metadata.api_format,
|
||||
transport,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
auth_header,
|
||||
auth_value,
|
||||
provider_request_body,
|
||||
upstream_is_stream,
|
||||
needs_bidirectional_conversion,
|
||||
kiro_auth,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(upstream_url) = (if needs_bidirectional_conversion {
|
||||
build_cross_format_openai_responses_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
&mapped_model,
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
)
|
||||
} else {
|
||||
build_local_openai_responses_upstream_url(
|
||||
parts,
|
||||
transport,
|
||||
api_format_alias_matches(provider_api_format, "openai:responses:compact"),
|
||||
)
|
||||
}) else {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let extra_headers = antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default();
|
||||
let Some(resolved_headers) =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport,
|
||||
provider_api_format,
|
||||
same_format,
|
||||
headers: &parts.headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
extra_headers: &extra_headers,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
upstream_is_stream,
|
||||
})
|
||||
else {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
spec_metadata.api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
let mut provider_request_headers = resolved_headers.headers;
|
||||
apply_codex_openai_responses_special_headers(
|
||||
&mut provider_request_headers,
|
||||
&provider_request_body,
|
||||
&parts.headers,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
Some(trace_id),
|
||||
transport.key.decrypted_auth_config.as_deref(),
|
||||
);
|
||||
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats(spec_metadata.api_format, provider_api_format);
|
||||
|
||||
debug!(
|
||||
event_name = "local_openai_responses_upstream_url_resolved",
|
||||
log_type = "debug",
|
||||
trace_id = %trace_id,
|
||||
candidate_id = %candidate_id,
|
||||
candidate_index,
|
||||
provider_id = %candidate.provider_id,
|
||||
endpoint_id = %candidate.endpoint_id,
|
||||
key_id = %candidate.key_id,
|
||||
provider_type = %transport.provider.provider_type,
|
||||
client_api_format = spec_metadata.api_format,
|
||||
provider_api_format = %provider_api_format,
|
||||
execution_strategy = execution_strategy.as_str(),
|
||||
conversion_mode = conversion_mode.as_str(),
|
||||
base_url = %transport.endpoint.base_url,
|
||||
custom_path = ?transport.endpoint.custom_path,
|
||||
request_path = %parts.uri.path(),
|
||||
request_query = ?parts.uri.query(),
|
||||
mapped_model = %mapped_model,
|
||||
upstream_url = %upstream_url,
|
||||
upstream_is_stream,
|
||||
"gateway resolved local openai responses upstream url"
|
||||
);
|
||||
|
||||
Some(LocalOpenAiResponsesCandidatePayloadParts {
|
||||
auth_header: resolved_headers.auth_header,
|
||||
auth_value: resolved_headers.auth_value,
|
||||
mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
is_antigravity: is_antigravity
|
||||
|| antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
|
||||
envelope_name: if is_antigravity || antigravity_auth.is_some() {
|
||||
Some(ANTIGRAVITY_ENVELOPE_NAME)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
upstream_is_stream,
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
|
||||
fn api_format_alias_matches(left: &str, right: &str) -> bool {
|
||||
crate::ai_serving::api_format_alias_matches(left, right)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_kiro_openai_responses_payload_parts(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
original_body_json: &serde_json::Value,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
eligible: &EligibleLocalExecutionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
client_api_format: &str,
|
||||
transport: &Arc<GatewayProviderTransportSnapshot>,
|
||||
provider_api_format: &str,
|
||||
mapped_model: String,
|
||||
auth_header: String,
|
||||
auth_value: String,
|
||||
claude_request_body: Value,
|
||||
upstream_is_stream: bool,
|
||||
needs_bidirectional_conversion: bool,
|
||||
kiro_auth: &KiroRequestAuth,
|
||||
) -> Option<LocalOpenAiResponsesCandidatePayloadParts> {
|
||||
let candidate = &eligible.candidate;
|
||||
let provider_request_body = match build_kiro_provider_request_body(
|
||||
&claude_request_body,
|
||||
&mapped_model,
|
||||
&kiro_auth.auth_config,
|
||||
transport.endpoint.body_rules.as_ref(),
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"provider_request_body_build_failed",
|
||||
CandidateFailureDiagnostic::envelope_build_failed(
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_kiro_envelope",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let upstream_url = match build_kiro_cross_format_upstream_url(
|
||||
transport,
|
||||
&mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
parts.uri.query(),
|
||||
kiro_auth.auth_config.effective_api_region(),
|
||||
) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"upstream_url_missing",
|
||||
CandidateFailureDiagnostic::upstream_url_missing(
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_kiro_url",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let provider_request_headers = match build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &parts.headers,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: original_body_json,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
auth_config: &kiro_auth.auth_config,
|
||||
machine_id: kiro_auth.machine_id.as_str(),
|
||||
}) {
|
||||
Some(headers) => headers,
|
||||
None => {
|
||||
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
"transport_header_rules_apply_failed",
|
||||
CandidateFailureDiagnostic::header_rules_apply_failed(
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
"openai_responses_kiro_headers",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (execution_strategy, conversion_mode) =
|
||||
ai_local_execution_contract_for_formats(client_api_format, provider_api_format);
|
||||
|
||||
debug!(
|
||||
event_name = "local_openai_responses_kiro_upstream_url_resolved",
|
||||
log_type = "debug",
|
||||
trace_id = %trace_id,
|
||||
candidate_id = %candidate_id,
|
||||
candidate_index,
|
||||
provider_id = %candidate.provider_id,
|
||||
endpoint_id = %candidate.endpoint_id,
|
||||
key_id = %candidate.key_id,
|
||||
provider_type = %transport.provider.provider_type,
|
||||
client_api_format = client_api_format,
|
||||
provider_api_format = %provider_api_format,
|
||||
execution_strategy = execution_strategy.as_str(),
|
||||
conversion_mode = conversion_mode.as_str(),
|
||||
upstream_url = %upstream_url,
|
||||
upstream_is_stream,
|
||||
"gateway resolved local openai responses kiro upstream url"
|
||||
);
|
||||
|
||||
Some(LocalOpenAiResponsesCandidatePayloadParts {
|
||||
auth_header,
|
||||
auth_value,
|
||||
mapped_model,
|
||||
provider_api_format: provider_api_format.to_string(),
|
||||
provider_request_body,
|
||||
provider_request_headers,
|
||||
upstream_url,
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
is_antigravity: false,
|
||||
envelope_name: Some(KIRO_ENVELOPE_NAME),
|
||||
upstream_is_stream,
|
||||
transport: Arc::clone(transport),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_serving::planner::candidate_materialization::{
|
||||
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic,
|
||||
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_metadata::{
|
||||
build_local_execution_candidate_contract_metadata,
|
||||
build_local_execution_candidate_contract_metadata_for_candidate,
|
||||
LocalExecutionCandidateMetadataParts,
|
||||
};
|
||||
use crate::ai_serving::planner::candidate_source::{
|
||||
preselect_local_execution_candidates_with_serving, LocalCandidatePreselectionKeyMode,
|
||||
};
|
||||
use crate::ai_serving::planner::common::extract_standard_requested_model;
|
||||
use crate::ai_serving::planner::decision_input::{
|
||||
build_local_requested_model_decision_input, resolve_local_authenticated_decision_input,
|
||||
};
|
||||
use crate::ai_serving::planner::materialization_policy::{
|
||||
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::set_local_runtime_miss_diagnostic_reason;
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::planner::CandidateFailureDiagnostic;
|
||||
use crate::ai_serving::{
|
||||
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
|
||||
resolve_local_decision_execution_runtime_auth_context, ExecutionRuntimeAuthContext,
|
||||
GatewayControlDecision, PlannerAppState,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::LocalOpenAiResponsesSpec;
|
||||
|
||||
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiResponsesCandidateAttempt;
|
||||
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiResponsesDecisionInput;
|
||||
|
||||
pub(crate) async fn resolve_local_openai_responses_decision_input(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Option<LocalOpenAiResponsesDecisionInput> {
|
||||
let Some(auth_context) = resolve_local_decision_execution_runtime_auth_context(decision) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
route_class = ?decision.route_class,
|
||||
route_family = ?decision.route_family,
|
||||
route_kind = ?decision.route_kind,
|
||||
"gateway local openai responses decision skipped: missing_auth_context"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
extract_standard_requested_model(body_json).as_deref(),
|
||||
"missing_auth_context",
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(requested_model) = extract_standard_requested_model(body_json) else {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
"gateway local openai responses decision skipped: missing_requested_model"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
None,
|
||||
"missing_requested_model",
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let resolved_input = match resolve_local_authenticated_decision_input(
|
||||
state,
|
||||
auth_context.clone(),
|
||||
Some(requested_model.as_str()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(resolved_input)) => resolved_input,
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
user_id = %auth_context.user_id,
|
||||
api_key_id = %auth_context.api_key_id,
|
||||
"gateway local openai responses decision skipped: auth_snapshot_missing"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_missing",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
error = ?err,
|
||||
"gateway local openai responses decision auth snapshot read failed"
|
||||
);
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
Some(requested_model.as_str()),
|
||||
"auth_snapshot_read_failed",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(build_local_requested_model_decision_input(
|
||||
resolved_input,
|
||||
requested_model,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalOpenAiResponsesSpec,
|
||||
) -> Result<(Vec<LocalOpenAiResponsesCandidateAttempt>, usize), GatewayError> {
|
||||
let spec_metadata = local_openai_responses_spec_metadata(spec);
|
||||
let planner_state = PlannerAppState::new(state);
|
||||
let sticky_session_token = extract_pool_sticky_session_token(body_json);
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiResponsesDecision,
|
||||
);
|
||||
let preselection = preselect_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
spec_metadata.api_format,
|
||||
&input.requested_model,
|
||||
spec_metadata.require_streaming,
|
||||
input.required_capabilities.as_ref(),
|
||||
&input.auth_snapshot,
|
||||
true,
|
||||
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
planner_state,
|
||||
trace_id,
|
||||
spec_metadata.api_format,
|
||||
Some(&input.requested_model),
|
||||
Some(&input.auth_snapshot),
|
||||
input.required_capabilities.as_ref(),
|
||||
sticky_session_token.as_deref(),
|
||||
persistence_policy,
|
||||
preselection.candidates,
|
||||
preselection.skipped_candidates,
|
||||
LocalCandidateResolutionMode::Standard,
|
||||
|eligible| {
|
||||
let provider_api_format = eligible.provider_api_format.clone();
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
&provider_api_format,
|
||||
);
|
||||
Some(build_local_execution_candidate_contract_metadata(
|
||||
LocalExecutionCandidateMetadataParts {
|
||||
eligible,
|
||||
provider_api_format: provider_api_format.as_str(),
|
||||
client_api_format: spec_metadata.api_format,
|
||||
extra_fields: serde_json::Map::new(),
|
||||
},
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
eligible.candidate.endpoint_api_format.as_str(),
|
||||
))
|
||||
},
|
||||
|mut skipped_candidate| {
|
||||
let provider_api_format = skipped_candidate
|
||||
.transport
|
||||
.as_ref()
|
||||
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
|
||||
.unwrap_or_else(|| {
|
||||
skipped_candidate
|
||||
.candidate
|
||||
.endpoint_api_format
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
});
|
||||
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
|
||||
spec_metadata.api_format,
|
||||
&provider_api_format,
|
||||
);
|
||||
skipped_candidate.extra_data = Some(
|
||||
build_local_execution_candidate_contract_metadata_for_candidate(
|
||||
&skipped_candidate.candidate,
|
||||
skipped_candidate.transport_ref(),
|
||||
provider_api_format.as_str(),
|
||||
spec_metadata.api_format,
|
||||
serde_json::Map::new(),
|
||||
execution_strategy,
|
||||
conversion_mode,
|
||||
provider_api_format.as_str(),
|
||||
),
|
||||
);
|
||||
skipped_candidate
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((outcome.attempts, outcome.candidate_count))
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_skipped_local_openai_responses_candidate(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiResponsesDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn mark_skipped_local_openai_responses_candidate_with_extra_data(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
extra_data: Option<serde_json::Value>,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiResponsesDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_extra_data(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
extra_data,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
|
||||
state: &AppState,
|
||||
input: &LocalOpenAiResponsesDecisionInput,
|
||||
trace_id: &str,
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
candidate_index: u32,
|
||||
candidate_id: &str,
|
||||
skip_reason: &'static str,
|
||||
diagnostic: CandidateFailureDiagnostic,
|
||||
) {
|
||||
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
|
||||
let persistence_policy = build_local_candidate_persistence_policy(
|
||||
auth_context,
|
||||
input.required_capabilities.as_ref(),
|
||||
LocalCandidatePersistencePolicyKind::OpenAiResponsesDecision,
|
||||
);
|
||||
mark_skipped_local_execution_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
trace_id,
|
||||
persistence_policy.skipped,
|
||||
candidate,
|
||||
candidate_index,
|
||||
candidate_id,
|
||||
skip_reason,
|
||||
diagnostic,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt};
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
use crate::{AiExecutionDecision, AppState, GatewayError};
|
||||
|
||||
mod decision;
|
||||
mod plans;
|
||||
|
||||
use self::decision::{
|
||||
materialize_local_openai_responses_candidate_attempts,
|
||||
maybe_build_local_openai_responses_decision_payload_for_candidate,
|
||||
resolve_local_openai_responses_decision_input,
|
||||
};
|
||||
use self::plans::{
|
||||
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
|
||||
resolve_sync_spec,
|
||||
};
|
||||
|
||||
pub(crate) async fn build_local_openai_responses_sync_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_sync_plan_and_reports(state, parts, trace_id, decision, body_json, spec).await
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_openai_responses_stream_plan_and_reports_for_kind(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec).await
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_sync_local_openai_responses_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) = resolve_local_openai_responses_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (attempts, _) = materialize_local_openai_responses_candidate_attempts(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_stream_local_openai_responses_decision_payload(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<AiExecutionDecision>, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(input) = resolve_local_openai_responses_decision_input(
|
||||
state, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (attempts, _) = materialize_local_openai_responses_candidate_attempts(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for attempt in attempts {
|
||||
if let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(Some(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
use tracing::warn;
|
||||
|
||||
use super::decision::{
|
||||
materialize_local_openai_responses_candidate_attempts,
|
||||
maybe_build_local_openai_responses_decision_payload_for_candidate,
|
||||
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesSpec,
|
||||
};
|
||||
use crate::ai_serving::planner::plan_builders::{
|
||||
build_openai_responses_stream_plan_from_decision,
|
||||
build_openai_responses_sync_plan_from_decision, AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::planner::runtime_miss::{
|
||||
apply_local_runtime_candidate_evaluation_progress,
|
||||
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
|
||||
use crate::ai_serving::GatewayControlDecision;
|
||||
pub(crate) use crate::ai_serving::{
|
||||
resolve_openai_responses_stream_spec as resolve_stream_spec,
|
||||
resolve_openai_responses_sync_spec as resolve_sync_spec,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
pub(super) async fn build_local_sync_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalOpenAiResponsesSpec,
|
||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_responses_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_openai_responses_decision_input(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
|
||||
let (attempts, candidate_count) = materialize_local_openai_responses_candidate_attempts(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_openai_responses_sync_plan_from_decision(
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
spec.compact,
|
||||
) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local openai responses sync decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_sync_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
pub(super) async fn build_local_stream_plan_and_reports(
|
||||
state: &AppState,
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
spec: LocalOpenAiResponsesSpec,
|
||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||
let spec_metadata = local_openai_responses_spec_metadata(spec);
|
||||
let Some(input) = resolve_local_openai_responses_decision_input(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
body_json,
|
||||
spec_metadata.decision_kind,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
set_local_runtime_miss_diagnostic_reason(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
spec_metadata.decision_kind,
|
||||
Some(input.requested_model.as_str()),
|
||||
"candidate_evaluation_incomplete",
|
||||
);
|
||||
|
||||
let (attempts, candidate_count) = materialize_local_openai_responses_candidate_attempts(
|
||||
state, trace_id, &input, body_json, spec,
|
||||
)
|
||||
.await?;
|
||||
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
|
||||
if candidate_count == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut plans = Vec::new();
|
||||
for attempt in attempts {
|
||||
let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
|
||||
state, parts, trace_id, body_json, &input, attempt, spec,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match build_openai_responses_stream_plan_from_decision(
|
||||
parts,
|
||||
body_json,
|
||||
payload,
|
||||
spec.compact,
|
||||
) {
|
||||
Ok(Some(value)) => plans.push(value),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
trace_id = %trace_id,
|
||||
api_format = spec_metadata.api_format,
|
||||
error = ?err,
|
||||
"gateway local openai responses stream decision plan build failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_local_runtime_candidate_terminal_reason(state, trace_id, "no_local_stream_plans");
|
||||
Ok(plans)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::RequestBody;
|
||||
|
||||
use super::{
|
||||
augment_sync_report_context, build_ai_execution_plan_from_decision, take_ai_decision_plan_core,
|
||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
||||
AiStreamAttempt, AiSyncAttempt,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_standard_plan_fallback_headers, StandardPlanFallbackAcceptPolicy,
|
||||
StandardPlanFallbackHeadersInput,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
generic_decision_missing_exact_provider_request,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
};
|
||||
use crate::{AiExecutionDecision, GatewayError};
|
||||
|
||||
pub(crate) fn build_standard_sync_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
) -> Result<Option<AiSyncAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: payload.upstream_is_stream,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy: StandardPlanFallbackAcceptPolicy::TextEventStreamIfStreaming,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let stream = payload.upstream_is_stream;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiSyncAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn build_standard_stream_plan_from_decision(
|
||||
parts: &http::request::Parts,
|
||||
_body_json: &serde_json::Value,
|
||||
payload: AiExecutionDecision,
|
||||
_inject_stream_flag: bool,
|
||||
) -> Result<Option<AiStreamAttempt>, GatewayError> {
|
||||
let mut payload = payload;
|
||||
if generic_decision_missing_exact_provider_request(&payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(core) = take_ai_decision_plan_core(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(url) = take_non_empty_string(&mut payload.upstream_url) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(auth_pair) = take_ai_upstream_auth_pair(&mut payload) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(provider_request_body_value) = payload.provider_request_body.take() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let envelope_name = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("envelope_name"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let accept_policy = if provider_adaptation_requires_eventstream_accept(
|
||||
envelope_name,
|
||||
core.provider_api_format.as_str(),
|
||||
) {
|
||||
StandardPlanFallbackAcceptPolicy::ProviderEventStreamIfMissing
|
||||
} else {
|
||||
StandardPlanFallbackAcceptPolicy::TextEventStreamRequired
|
||||
};
|
||||
let mut provider_request_headers =
|
||||
build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &parts.headers,
|
||||
existing_provider_request_headers: std::mem::take(
|
||||
&mut payload.provider_request_headers,
|
||||
),
|
||||
auth_header: auth_pair.as_ref().map(|pair| pair.header.as_str()),
|
||||
auth_value: auth_pair.as_ref().map(|pair| pair.value.as_str()),
|
||||
extra_headers: &BTreeMap::new(),
|
||||
content_type: payload.content_type.as_deref(),
|
||||
provider_api_format: core.provider_api_format.as_str(),
|
||||
client_api_format: core.client_api_format.as_str(),
|
||||
upstream_is_stream: true,
|
||||
build_from_request_when_empty: false,
|
||||
accept_policy,
|
||||
});
|
||||
let content_type = payload
|
||||
.content_type
|
||||
.take()
|
||||
.or_else(|| Some("application/json".to_string()));
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context.take(),
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = build_ai_execution_plan_from_decision(
|
||||
&mut payload,
|
||||
AiExecutionPlanFromDecisionParts {
|
||||
core,
|
||||
method: "POST".to_string(),
|
||||
url,
|
||||
headers: std::mem::take(&mut provider_request_headers),
|
||||
content_type,
|
||||
body: RequestBody::from_json(provider_request_body_value),
|
||||
stream: true,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Some(AiStreamAttempt {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user