mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-15 07:30:21 +08:00
feat(gateway): harden provider request execution
Preserve exact request payloads and model client surface and API operation explicitly. Add Anthropic compatibility profiles, bounded stream commitment, and scoped OAuth retry behavior across provider transports.
This commit is contained in:
Generated
+1
@@ -103,6 +103,7 @@ dependencies = [
|
|||||||
"aether-pool-core",
|
"aether-pool-core",
|
||||||
"aether-scheduler-core",
|
"aether-scheduler-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"http",
|
"http",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -87,28 +87,36 @@ pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
|||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind(
|
let plan_kind =
|
||||||
decision.route_class.as_deref(),
|
aether_ai_formats::api::resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||||
decision.route_family.as_deref(),
|
decision.route_class.as_deref(),
|
||||||
decision.route_kind.as_deref(),
|
decision.route_family.as_deref(),
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.route_kind.as_deref(),
|
||||||
&parts.method,
|
decision.client_surface,
|
||||||
parts.uri.path(),
|
decision.request_auth_channel.as_deref(),
|
||||||
)
|
&parts.method,
|
||||||
|
parts.uri.path(),
|
||||||
|
)?;
|
||||||
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||||
|
.then_some(plan_kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind(
|
let plan_kind =
|
||||||
decision.route_class.as_deref(),
|
aether_ai_formats::api::resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||||
decision.route_family.as_deref(),
|
decision.route_class.as_deref(),
|
||||||
decision.route_kind.as_deref(),
|
decision.route_family.as_deref(),
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.route_kind.as_deref(),
|
||||||
&parts.method,
|
decision.client_surface,
|
||||||
parts.uri.path(),
|
decision.request_auth_channel.as_deref(),
|
||||||
)
|
&parts.method,
|
||||||
|
parts.uri.path(),
|
||||||
|
)?;
|
||||||
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||||
|
.then_some(plan_kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_matching_stream_request(
|
pub(crate) fn is_matching_stream_request(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ fn same_format_claude_local_stream_rewriter_sanitizes_read_input_json_delta() {
|
|||||||
let report_context = json!({
|
let report_context = json!({
|
||||||
"provider_api_format": "claude:messages",
|
"provider_api_format": "claude:messages",
|
||||||
"client_api_format": "claude:messages",
|
"client_api_format": "claude:messages",
|
||||||
|
"anthropic_compatibility_profile": "claude_code_legacy",
|
||||||
"needs_conversion": false,
|
"needs_conversion": false,
|
||||||
});
|
});
|
||||||
let mut rewriter =
|
let mut rewriter =
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ fn test_decision() -> GatewayControlDecision {
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("openai".to_string()),
|
route_family: Some("openai".to_string()),
|
||||||
route_kind: Some("compact".to_string()),
|
route_kind: Some("compact".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: Some("openai:responses:compact".to_string()),
|
auth_endpoint_signature: Some("openai:responses:compact".to_string()),
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
@@ -1923,6 +1926,9 @@ fn local_finalize_handles_claude_chat_cross_format_sync_response_from_openai_cha
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("claude".to_string()),
|
route_family: Some("claude".to_string()),
|
||||||
route_kind: Some("chat".to_string()),
|
route_kind: Some("chat".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: Some("claude:messages".to_string()),
|
auth_endpoint_signature: Some("claude:messages".to_string()),
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
@@ -1991,6 +1997,9 @@ fn local_finalize_handles_gemini_cli_cross_format_sync_response_from_claude_cli(
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("gemini".to_string()),
|
route_family: Some("gemini".to_string()),
|
||||||
route_kind: Some("cli".to_string()),
|
route_kind: Some("cli".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: Some("gemini:generate_content".to_string()),
|
auth_endpoint_signature: Some("gemini:generate_content".to_string()),
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub(crate) use self::transport::{
|
|||||||
request_pair_allowed_for_transport, request_pair_direct_auth,
|
request_pair_allowed_for_transport, request_pair_direct_auth,
|
||||||
request_pair_transport_unsupported_reason, CandidateTransportPolicyFacts,
|
request_pair_transport_unsupported_reason, CandidateTransportPolicyFacts,
|
||||||
};
|
};
|
||||||
pub(crate) use crate::control::GatewayControlDecision;
|
pub(crate) use crate::control::{GatewayControlDecision, GatewayCredentialCarrier};
|
||||||
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
pub(crate) use crate::execution_runtime::{ConversionMode, ExecutionStrategy};
|
||||||
pub(crate) use crate::headers::RequestOrigin;
|
pub(crate) use crate::headers::RequestOrigin;
|
||||||
pub(crate) use aether_ai_serving::{
|
pub(crate) use aether_ai_serving::{
|
||||||
@@ -89,6 +89,7 @@ pub(crate) fn build_provider_transport_request_url(
|
|||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
request_query: Option<&str>,
|
request_query: Option<&str>,
|
||||||
kiro_api_region: Option<&str>,
|
kiro_api_region: Option<&str>,
|
||||||
|
api_operation: Option<ApiOperation>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
self::transport::build_transport_request_url(
|
self::transport::build_transport_request_url(
|
||||||
transport,
|
transport,
|
||||||
@@ -98,6 +99,7 @@ pub(crate) fn build_provider_transport_request_url(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_query,
|
request_query,
|
||||||
kiro_api_region,
|
kiro_api_region,
|
||||||
|
api_operation,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -109,6 +111,7 @@ pub(crate) fn build_provider_transport_request_url_for_request_body(
|
|||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
request_query: Option<&str>,
|
request_query: Option<&str>,
|
||||||
kiro_api_region: Option<&str>,
|
kiro_api_region: Option<&str>,
|
||||||
|
api_operation: Option<ApiOperation>,
|
||||||
provider_request_body: Option<&serde_json::Value>,
|
provider_request_body: Option<&serde_json::Value>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
self::transport::build_transport_request_url_for_request_body(
|
self::transport::build_transport_request_url_for_request_body(
|
||||||
@@ -119,6 +122,7 @@ pub(crate) fn build_provider_transport_request_url_for_request_body(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_query,
|
request_query,
|
||||||
kiro_api_region,
|
kiro_api_region,
|
||||||
|
api_operation,
|
||||||
},
|
},
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub(crate) async fn build_antigravity_v1internal_provider_request(
|
|||||||
input.upstream_is_stream,
|
input.upstream_is_stream,
|
||||||
input.parts.uri.query(),
|
input.parts.uri.query(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(&payload.body),
|
Some(&payload.body),
|
||||||
)
|
)
|
||||||
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
|
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub(crate) struct LocalExecutionCandidateAttempt {
|
|||||||
pub(crate) struct LocalExecutionCandidateAttemptSource<'a> {
|
pub(crate) struct LocalExecutionCandidateAttemptSource<'a> {
|
||||||
items: VecDeque<LocalExecutionCandidateAttemptSourceItem<'a>>,
|
items: VecDeque<LocalExecutionCandidateAttemptSourceItem<'a>>,
|
||||||
skipped_provider_ids: BTreeSet<String>,
|
skipped_provider_ids: BTreeSet<String>,
|
||||||
|
skipped_endpoint_ids: BTreeSet<String>,
|
||||||
|
skipped_credential_ids: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecorateSkippedCandidateFn<'a> = Arc<
|
type DecorateSkippedCandidateFn<'a> = Arc<
|
||||||
@@ -80,6 +82,10 @@ pub(crate) trait LocalExecutionAttemptSource<T>: Send {
|
|||||||
|
|
||||||
async fn drain_execution_attempts(&mut self) -> Result<Vec<T>, GatewayError>;
|
async fn drain_execution_attempts(&mut self) -> Result<Vec<T>, GatewayError>;
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError>;
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError>;
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError>;
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +117,8 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
Self {
|
Self {
|
||||||
items,
|
items,
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +131,12 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
};
|
};
|
||||||
match front {
|
match front {
|
||||||
LocalExecutionCandidateAttemptSourceItem::Static { attempts } => {
|
LocalExecutionCandidateAttemptSourceItem::Static { attempts } => {
|
||||||
if dispatch_sequence_provider_is_skipped(attempts, &self.skipped_provider_ids) {
|
if dispatch_sequence_candidate_is_skipped(
|
||||||
|
attempts,
|
||||||
|
&self.skipped_provider_ids,
|
||||||
|
&self.skipped_endpoint_ids,
|
||||||
|
&self.skipped_credential_ids,
|
||||||
|
) {
|
||||||
self.items.pop_front();
|
self.items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -141,10 +154,20 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
pending_attempts,
|
pending_attempts,
|
||||||
pool_exhaustion_persistence,
|
pool_exhaustion_persistence,
|
||||||
} => {
|
} => {
|
||||||
if self.skipped_provider_ids.contains(cursor.provider_id()) {
|
if self.skipped_provider_ids.contains(cursor.provider_id())
|
||||||
|
|| self.skipped_endpoint_ids.contains(cursor.endpoint_id())
|
||||||
|
{
|
||||||
self.items.pop_front();
|
self.items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if dispatch_sequence_candidate_is_skipped(
|
||||||
|
pending_attempts,
|
||||||
|
&self.skipped_provider_ids,
|
||||||
|
&self.skipped_endpoint_ids,
|
||||||
|
&self.skipped_credential_ids,
|
||||||
|
) {
|
||||||
|
*pending_attempts = DispatchSequence::new(Vec::new());
|
||||||
|
}
|
||||||
if let Some(attempt) = next_attempt_from_dispatch_sequence(pending_attempts) {
|
if let Some(attempt) = next_attempt_from_dispatch_sequence(pending_attempts) {
|
||||||
return Ok(Some(attempt));
|
return Ok(Some(attempt));
|
||||||
}
|
}
|
||||||
@@ -162,6 +185,14 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
self.items.pop_front();
|
self.items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if candidate_is_skipped(
|
||||||
|
&candidate,
|
||||||
|
&self.skipped_provider_ids,
|
||||||
|
&self.skipped_endpoint_ids,
|
||||||
|
&self.skipped_credential_ids,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
*pending_attempts = dispatch_sequence_from_attempts(
|
*pending_attempts = dispatch_sequence_from_attempts(
|
||||||
build_unpersisted_local_execution_candidate_attempts(
|
build_unpersisted_local_execution_candidate_attempts(
|
||||||
candidate,
|
candidate,
|
||||||
@@ -174,6 +205,12 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
for provider_id in &self.skipped_provider_ids {
|
for provider_id in &self.skipped_provider_ids {
|
||||||
cursor.skip_provider(provider_id);
|
cursor.skip_provider(provider_id);
|
||||||
}
|
}
|
||||||
|
for endpoint_id in &self.skipped_endpoint_ids {
|
||||||
|
cursor.skip_endpoint(endpoint_id);
|
||||||
|
}
|
||||||
|
for key_id in &self.skipped_credential_ids {
|
||||||
|
cursor.skip_credential(key_id);
|
||||||
|
}
|
||||||
let Some(attempt) = cursor.next_attempt().await? else {
|
let Some(attempt) = cursor.next_attempt().await? else {
|
||||||
self.items.pop_front();
|
self.items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
@@ -201,6 +238,32 @@ impl<'a> LocalExecutionCandidateAttemptSource<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn skip_endpoint(&mut self, endpoint_id: &str) {
|
||||||
|
let endpoint_id = endpoint_id.trim();
|
||||||
|
if endpoint_id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.skipped_endpoint_ids.insert(endpoint_id.to_string());
|
||||||
|
for item in &mut self.items {
|
||||||
|
if let LocalExecutionCandidateAttemptSourceItem::RequestedModelPage { cursor } = item {
|
||||||
|
cursor.skip_endpoint(endpoint_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn skip_credential(&mut self, key_id: &str) {
|
||||||
|
let key_id = key_id.trim();
|
||||||
|
if key_id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.skipped_credential_ids.insert(key_id.to_string());
|
||||||
|
for item in &mut self.items {
|
||||||
|
if let LocalExecutionCandidateAttemptSourceItem::RequestedModelPage { cursor } = item {
|
||||||
|
cursor.skip_credential(key_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalExecutionCandidateAttempt {
|
impl LocalExecutionCandidateAttempt {
|
||||||
@@ -668,6 +731,8 @@ where
|
|||||||
LocalExecutionCandidateAttemptSource {
|
LocalExecutionCandidateAttemptSource {
|
||||||
items,
|
items,
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
},
|
},
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
@@ -802,6 +867,8 @@ where
|
|||||||
page_cursor,
|
page_cursor,
|
||||||
pending_items: VecDeque::new(),
|
pending_items: VecDeque::new(),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
candidate_count: 0,
|
candidate_count: 0,
|
||||||
next_candidate_index: 0,
|
next_candidate_index: 0,
|
||||||
remembered_affinity: false,
|
remembered_affinity: false,
|
||||||
@@ -825,6 +892,8 @@ where
|
|||||||
LocalExecutionCandidateAttemptSource {
|
LocalExecutionCandidateAttemptSource {
|
||||||
items,
|
items,
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
},
|
},
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
@@ -851,6 +920,8 @@ struct RequestedModelAttemptPageCursor<'a> {
|
|||||||
page_cursor: LocalCandidatePreselectionPageCursor<'a>,
|
page_cursor: LocalCandidatePreselectionPageCursor<'a>,
|
||||||
pending_items: VecDeque<LocalExecutionCandidateAttemptSourceItem<'a>>,
|
pending_items: VecDeque<LocalExecutionCandidateAttemptSourceItem<'a>>,
|
||||||
skipped_provider_ids: BTreeSet<String>,
|
skipped_provider_ids: BTreeSet<String>,
|
||||||
|
skipped_endpoint_ids: BTreeSet<String>,
|
||||||
|
skipped_credential_ids: BTreeSet<String>,
|
||||||
candidate_count: usize,
|
candidate_count: usize,
|
||||||
next_candidate_index: u32,
|
next_candidate_index: u32,
|
||||||
remembered_affinity: bool,
|
remembered_affinity: bool,
|
||||||
@@ -864,6 +935,14 @@ impl<'a> RequestedModelAttemptPageCursor<'a> {
|
|||||||
self.skipped_provider_ids.insert(provider_id.to_string());
|
self.skipped_provider_ids.insert(provider_id.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn skip_endpoint(&mut self, endpoint_id: &str) {
|
||||||
|
self.skipped_endpoint_ids.insert(endpoint_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_credential(&mut self, key_id: &str) {
|
||||||
|
self.skipped_credential_ids.insert(key_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
async fn next_attempt(
|
async fn next_attempt(
|
||||||
&mut self,
|
&mut self,
|
||||||
) -> Result<Option<LocalExecutionCandidateAttempt>, GatewayError> {
|
) -> Result<Option<LocalExecutionCandidateAttempt>, GatewayError> {
|
||||||
@@ -871,8 +950,13 @@ impl<'a> RequestedModelAttemptPageCursor<'a> {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
if let Some(attempt) =
|
if let Some(attempt) = pop_attempt_from_items(
|
||||||
pop_attempt_from_items(&mut self.pending_items, &self.skipped_provider_ids).await
|
&mut self.pending_items,
|
||||||
|
&self.skipped_provider_ids,
|
||||||
|
&self.skipped_endpoint_ids,
|
||||||
|
&self.skipped_credential_ids,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
return Ok(Some(attempt));
|
return Ok(Some(attempt));
|
||||||
}
|
}
|
||||||
@@ -1075,12 +1159,19 @@ fn page_is_exact_auth_api_key_concurrency_limited(
|
|||||||
async fn pop_attempt_from_items(
|
async fn pop_attempt_from_items(
|
||||||
items: &mut VecDeque<LocalExecutionCandidateAttemptSourceItem<'_>>,
|
items: &mut VecDeque<LocalExecutionCandidateAttemptSourceItem<'_>>,
|
||||||
skipped_provider_ids: &BTreeSet<String>,
|
skipped_provider_ids: &BTreeSet<String>,
|
||||||
|
skipped_endpoint_ids: &BTreeSet<String>,
|
||||||
|
skipped_credential_ids: &BTreeSet<String>,
|
||||||
) -> Option<LocalExecutionCandidateAttempt> {
|
) -> Option<LocalExecutionCandidateAttempt> {
|
||||||
loop {
|
loop {
|
||||||
let front = items.front_mut()?;
|
let front = items.front_mut()?;
|
||||||
match front {
|
match front {
|
||||||
LocalExecutionCandidateAttemptSourceItem::Static { attempts } => {
|
LocalExecutionCandidateAttemptSourceItem::Static { attempts } => {
|
||||||
if dispatch_sequence_provider_is_skipped(attempts, skipped_provider_ids) {
|
if dispatch_sequence_candidate_is_skipped(
|
||||||
|
attempts,
|
||||||
|
skipped_provider_ids,
|
||||||
|
skipped_endpoint_ids,
|
||||||
|
skipped_credential_ids,
|
||||||
|
) {
|
||||||
items.pop_front();
|
items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1098,10 +1189,20 @@ async fn pop_attempt_from_items(
|
|||||||
pending_attempts,
|
pending_attempts,
|
||||||
pool_exhaustion_persistence,
|
pool_exhaustion_persistence,
|
||||||
} => {
|
} => {
|
||||||
if skipped_provider_ids.contains(cursor.provider_id()) {
|
if skipped_provider_ids.contains(cursor.provider_id())
|
||||||
|
|| skipped_endpoint_ids.contains(cursor.endpoint_id())
|
||||||
|
{
|
||||||
items.pop_front();
|
items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if dispatch_sequence_candidate_is_skipped(
|
||||||
|
pending_attempts,
|
||||||
|
skipped_provider_ids,
|
||||||
|
skipped_endpoint_ids,
|
||||||
|
skipped_credential_ids,
|
||||||
|
) {
|
||||||
|
*pending_attempts = DispatchSequence::new(Vec::new());
|
||||||
|
}
|
||||||
if let Some(attempt) = next_attempt_from_dispatch_sequence(pending_attempts) {
|
if let Some(attempt) = next_attempt_from_dispatch_sequence(pending_attempts) {
|
||||||
return Some(attempt);
|
return Some(attempt);
|
||||||
}
|
}
|
||||||
@@ -1119,6 +1220,14 @@ async fn pop_attempt_from_items(
|
|||||||
items.pop_front();
|
items.pop_front();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if candidate_is_skipped(
|
||||||
|
&candidate,
|
||||||
|
skipped_provider_ids,
|
||||||
|
skipped_endpoint_ids,
|
||||||
|
skipped_credential_ids,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
*pending_attempts = dispatch_sequence_from_attempts(
|
*pending_attempts = dispatch_sequence_from_attempts(
|
||||||
build_unpersisted_local_execution_candidate_attempts(
|
build_unpersisted_local_execution_candidate_attempts(
|
||||||
candidate,
|
candidate,
|
||||||
@@ -1780,15 +1889,33 @@ fn next_attempt_from_dispatch_sequence(
|
|||||||
Some(attempt)
|
Some(attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_sequence_provider_is_skipped(
|
fn dispatch_sequence_candidate_is_skipped(
|
||||||
sequence: &DispatchSequence<LocalExecutionCandidateAttempt>,
|
sequence: &DispatchSequence<LocalExecutionCandidateAttempt>,
|
||||||
skipped_provider_ids: &BTreeSet<String>,
|
skipped_provider_ids: &BTreeSet<String>,
|
||||||
|
skipped_endpoint_ids: &BTreeSet<String>,
|
||||||
|
skipped_credential_ids: &BTreeSet<String>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
sequence.peek_current().is_some_and(|item| {
|
sequence.peek_current().is_some_and(|item| {
|
||||||
skipped_provider_ids.contains(&item.candidate.eligible.candidate.provider_id)
|
candidate_is_skipped(
|
||||||
|
&item.candidate.eligible,
|
||||||
|
skipped_provider_ids,
|
||||||
|
skipped_endpoint_ids,
|
||||||
|
skipped_credential_ids,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn candidate_is_skipped(
|
||||||
|
candidate: &EligibleLocalExecutionCandidate,
|
||||||
|
skipped_provider_ids: &BTreeSet<String>,
|
||||||
|
skipped_endpoint_ids: &BTreeSet<String>,
|
||||||
|
skipped_credential_ids: &BTreeSet<String>,
|
||||||
|
) -> bool {
|
||||||
|
skipped_provider_ids.contains(&candidate.candidate.provider_id)
|
||||||
|
|| skipped_endpoint_ids.contains(&candidate.candidate.endpoint_id)
|
||||||
|
|| skipped_credential_ids.contains(&candidate.candidate.key_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_sequence_exhausted(
|
fn dispatch_sequence_exhausted(
|
||||||
sequence: &mut DispatchSequence<LocalExecutionCandidateAttempt>,
|
sequence: &mut DispatchSequence<LocalExecutionCandidateAttempt>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@@ -2341,6 +2468,8 @@ mod tests {
|
|||||||
page_cursor,
|
page_cursor,
|
||||||
pending_items: VecDeque::new(),
|
pending_items: VecDeque::new(),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
candidate_count: 0,
|
candidate_count: 0,
|
||||||
next_candidate_index: 0,
|
next_candidate_index: 0,
|
||||||
remembered_affinity: false,
|
remembered_affinity: false,
|
||||||
@@ -2436,6 +2565,8 @@ mod tests {
|
|||||||
page_cursor,
|
page_cursor,
|
||||||
pending_items: VecDeque::new(),
|
pending_items: VecDeque::new(),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
candidate_count: 0,
|
candidate_count: 0,
|
||||||
next_candidate_index: 0,
|
next_candidate_index: 0,
|
||||||
remembered_affinity: false,
|
remembered_affinity: false,
|
||||||
@@ -2634,6 +2765,8 @@ mod tests {
|
|||||||
),
|
),
|
||||||
}]),
|
}]),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = source
|
let first = source
|
||||||
@@ -2652,6 +2785,111 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dynamic_attempt_source_skips_credentials_and_endpoints_across_static_candidates() {
|
||||||
|
let key_a = sample_eligible("key-a", None);
|
||||||
|
let key_b = sample_eligible("key-b", None);
|
||||||
|
let mut key_c = sample_eligible("key-c", None);
|
||||||
|
key_c.candidate.endpoint_id = "endpoint-2".to_string();
|
||||||
|
Arc::make_mut(&mut key_c.transport).endpoint.id = "endpoint-2".to_string();
|
||||||
|
|
||||||
|
let static_item =
|
||||||
|
|candidate, candidate_index| LocalExecutionCandidateAttemptSourceItem::Static {
|
||||||
|
attempts: dispatch_sequence_from_attempts(
|
||||||
|
build_unpersisted_local_execution_candidate_attempts(
|
||||||
|
candidate,
|
||||||
|
candidate_index,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let mut source = LocalExecutionCandidateAttemptSource {
|
||||||
|
items: VecDeque::from([
|
||||||
|
static_item(key_a, 0),
|
||||||
|
static_item(key_b, 1),
|
||||||
|
static_item(key_c, 2),
|
||||||
|
]),
|
||||||
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
source.skip_credential("key-a");
|
||||||
|
let key_b_attempt = source
|
||||||
|
.next_attempt()
|
||||||
|
.await
|
||||||
|
.expect("candidate source should succeed")
|
||||||
|
.expect("a different credential should remain");
|
||||||
|
assert_eq!(key_b_attempt.eligible.candidate.key_id, "key-b");
|
||||||
|
|
||||||
|
source.skip_endpoint("endpoint-1");
|
||||||
|
let endpoint_2_attempt = source
|
||||||
|
.next_attempt()
|
||||||
|
.await
|
||||||
|
.expect("candidate source should succeed")
|
||||||
|
.expect("a different endpoint should remain");
|
||||||
|
assert_eq!(endpoint_2_attempt.eligible.candidate.key_id, "key-c");
|
||||||
|
assert_eq!(
|
||||||
|
endpoint_2_attempt.eligible.candidate.endpoint_id,
|
||||||
|
"endpoint-2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dynamic_attempt_source_filters_skipped_pool_pending_credential() {
|
||||||
|
let app = AppState::new().expect("state should build");
|
||||||
|
let mut pool_group = sample_eligible("pool-group", None);
|
||||||
|
pool_group.kind = LocalExecutionCandidateKind::PoolGroup;
|
||||||
|
pool_group.transport = sample_transport("pool-group", Some(json!({ "pool_advanced": {} })));
|
||||||
|
let pool_cursor = PoolKeyCursor::new(
|
||||||
|
PlannerAppState::new(&app),
|
||||||
|
pool_group,
|
||||||
|
None,
|
||||||
|
Some("gpt-5"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let pool_key_attempts = dispatch_sequence_from_attempts(
|
||||||
|
build_unpersisted_local_execution_candidate_attempts(
|
||||||
|
sample_eligible("pool-key-a", None),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
let mut fallback = sample_eligible("fallback-key", None);
|
||||||
|
fallback.candidate.provider_id = "provider-b".to_string();
|
||||||
|
Arc::make_mut(&mut fallback.transport).provider.id = "provider-b".to_string();
|
||||||
|
Arc::make_mut(&mut fallback.transport).key.provider_id = "provider-b".to_string();
|
||||||
|
let fallback_attempts = dispatch_sequence_from_attempts(
|
||||||
|
build_unpersisted_local_execution_candidate_attempts(fallback, 1).into(),
|
||||||
|
);
|
||||||
|
let mut source = LocalExecutionCandidateAttemptSource {
|
||||||
|
items: VecDeque::from([
|
||||||
|
LocalExecutionCandidateAttemptSourceItem::Pool {
|
||||||
|
cursor: pool_cursor,
|
||||||
|
candidate_index: 0,
|
||||||
|
pending_attempts: pool_key_attempts,
|
||||||
|
pool_exhaustion_persistence: None,
|
||||||
|
},
|
||||||
|
LocalExecutionCandidateAttemptSourceItem::Static {
|
||||||
|
attempts: fallback_attempts,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
source.skip_credential("pool-key-a");
|
||||||
|
let attempt = source
|
||||||
|
.next_attempt()
|
||||||
|
.await
|
||||||
|
.expect("candidate source should succeed")
|
||||||
|
.expect("fallback credential should remain");
|
||||||
|
|
||||||
|
assert_eq!(attempt.eligible.candidate.provider_id, "provider-b");
|
||||||
|
assert_eq!(attempt.eligible.candidate.key_id, "fallback-key");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skipped_provider_discards_pool_cursor_and_continues_with_next_provider() {
|
async fn skipped_provider_discards_pool_cursor_and_continues_with_next_provider() {
|
||||||
let app = AppState::new().expect("state should build");
|
let app = AppState::new().expect("state should build");
|
||||||
@@ -2686,6 +2924,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
source.skip_provider("provider-1");
|
source.skip_provider("provider-1");
|
||||||
@@ -2752,6 +2992,8 @@ mod tests {
|
|||||||
pool_exhaustion_persistence: Some(pool_exhaustion_persistence),
|
pool_exhaustion_persistence: Some(pool_exhaustion_persistence),
|
||||||
}]),
|
}]),
|
||||||
skipped_provider_ids: BTreeSet::new(),
|
skipped_provider_ids: BTreeSet::new(),
|
||||||
|
skipped_endpoint_ids: BTreeSet::new(),
|
||||||
|
skipped_credential_ids: BTreeSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(source
|
assert!(source
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::ai_serving::{
|
|||||||
};
|
};
|
||||||
pub(crate) use crate::ai_serving::{
|
pub(crate) use crate::ai_serving::{
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||||
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||||
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||||
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
use crate::ai_serving::planner::common::{
|
use crate::ai_serving::planner::common::{
|
||||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
CLAUDE_CLI_SYNC_PLAN_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||||
GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND,
|
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||||
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
|
GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND,
|
||||||
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND,
|
||||||
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
|
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||||
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
|
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
|
||||||
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND,
|
||||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
|
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
|
||||||
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_SEARCH_SYNC_PLAN_KIND,
|
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
OPENAI_SEARCH_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
|
||||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::planner::plan_builders::{
|
use crate::ai_serving::planner::plan_builders::{
|
||||||
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
|
||||||
@@ -109,6 +109,7 @@ fn build_sync_plan_payload_from_decision(
|
|||||||
}
|
}
|
||||||
CLAUDE_CHAT_SYNC_PLAN_KIND
|
CLAUDE_CHAT_SYNC_PLAN_KIND
|
||||||
| CLAUDE_CLI_SYNC_PLAN_KIND
|
| CLAUDE_CLI_SYNC_PLAN_KIND
|
||||||
|
| CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND
|
||||||
| OPENAI_EMBEDDING_SYNC_PLAN_KIND
|
| OPENAI_EMBEDDING_SYNC_PLAN_KIND
|
||||||
| OPENAI_RERANK_SYNC_PLAN_KIND => {
|
| OPENAI_RERANK_SYNC_PLAN_KIND => {
|
||||||
build_standard_sync_plan_from_decision(parts, body_json, payload)?
|
build_standard_sync_plan_from_decision(parts, body_json, payload)?
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ use tracing::warn;
|
|||||||
|
|
||||||
use crate::ai_serving::planner::common::extract_standard_requested_model;
|
use crate::ai_serving::planner::common::extract_standard_requested_model;
|
||||||
use crate::ai_serving::{
|
use crate::ai_serving::{
|
||||||
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot,
|
ClientSurface, ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot,
|
||||||
PlannerAppState, CODEX_RESPONSES_LITE_HEADER,
|
GatewayCredentialCarrier, GatewayProviderTransportSnapshot, PlannerAppState,
|
||||||
|
CODEX_RESPONSES_LITE_HEADER,
|
||||||
};
|
};
|
||||||
use crate::cache::CacheLoadObserver;
|
use crate::cache::CacheLoadObserver;
|
||||||
use crate::client_session_affinity::client_session_affinity_from_api_request;
|
use crate::client_session_affinity::client_session_affinity_from_api_request;
|
||||||
@@ -52,6 +53,8 @@ pub(crate) struct LocalRequestedModelDecisionInput {
|
|||||||
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
pub(crate) required_capabilities: Option<serde_json::Value>,
|
pub(crate) required_capabilities: Option<serde_json::Value>,
|
||||||
pub(crate) request_auth_channel: Option<String>,
|
pub(crate) request_auth_channel: Option<String>,
|
||||||
|
pub(crate) client_surface: Option<ClientSurface>,
|
||||||
|
pub(crate) gateway_credential_carrier: Option<GatewayCredentialCarrier>,
|
||||||
pub(crate) client_session_affinity: Option<ClientSessionAffinity>,
|
pub(crate) client_session_affinity: Option<ClientSessionAffinity>,
|
||||||
pub(crate) routing_policy: Option<ResolvedRoutingPolicy>,
|
pub(crate) routing_policy: Option<ResolvedRoutingPolicy>,
|
||||||
pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>,
|
pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>,
|
||||||
@@ -378,6 +381,8 @@ pub(crate) fn build_local_requested_model_decision_input(
|
|||||||
auth_snapshot: resolved_input.auth_snapshot,
|
auth_snapshot: resolved_input.auth_snapshot,
|
||||||
required_capabilities: resolved_input.required_capabilities,
|
required_capabilities: resolved_input.required_capabilities,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
@@ -1128,6 +1133,8 @@ mod tests {
|
|||||||
auth_snapshot: sample_auth_snapshot(),
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
required_capabilities: None,
|
required_capabilities: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
@@ -1323,6 +1330,8 @@ mod tests {
|
|||||||
auth_snapshot: sample_auth_snapshot(),
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
required_capabilities: None,
|
required_capabilities: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
@@ -1390,6 +1399,8 @@ mod tests {
|
|||||||
auth_snapshot: sample_auth_snapshot(),
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
required_capabilities: None,
|
required_capabilities: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
@@ -1459,6 +1470,59 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_request_routing_policy_cannot_restore_credentials_or_aether_internal_headers() {
|
||||||
|
for header_name in [
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"api-key",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"cookie",
|
||||||
|
"cookie2",
|
||||||
|
"set-cookie",
|
||||||
|
"x-aether-auth-user-id",
|
||||||
|
"x-aether-control-future",
|
||||||
|
] {
|
||||||
|
let mut input = sample_decision_input();
|
||||||
|
set_provider_request_rules(
|
||||||
|
&mut input,
|
||||||
|
&["gpt-5"],
|
||||||
|
json!([{
|
||||||
|
"type": "patch_headers",
|
||||||
|
"patch": [{
|
||||||
|
"op": "set",
|
||||||
|
"name": header_name,
|
||||||
|
"value": "must-not-reach-upstream"
|
||||||
|
}]
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
let mut decision = sample_decision();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
|
||||||
|
.expect_err("reserved provider header mutation should fail closed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&error,
|
||||||
|
GatewayError::Client {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"unexpected error for {header_name}: {error:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!decision
|
||||||
|
.provider_request_headers
|
||||||
|
.keys()
|
||||||
|
.any(|name| name.eq_ignore_ascii_case(header_name)),
|
||||||
|
"reserved header reached the provider decision: {header_name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codex_prompt_cache_identity_headers_are_terminal_after_routing_mutations() {
|
fn codex_prompt_cache_identity_headers_are_terminal_after_routing_mutations() {
|
||||||
let mut input = sample_decision_input();
|
let mut input = sample_decision_input();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub(crate) async fn build_gemini_cli_v1internal_provider_request(
|
|||||||
input.upstream_is_stream,
|
input.upstream_is_stream,
|
||||||
input.parts.uri.query(),
|
input.parts.uri.query(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
Some(&payload.body),
|
Some(&payload.body),
|
||||||
)
|
)
|
||||||
.ok_or(GeminiCliV1InternalRequestError::UpstreamUrlUnavailable)?;
|
.ok_or(GeminiCliV1InternalRequestError::UpstreamUrlUnavailable)?;
|
||||||
|
|||||||
+6
-2
@@ -81,6 +81,8 @@ pub(crate) async fn resolve_local_same_format_provider_decision_input(
|
|||||||
|
|
||||||
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
|
let mut input = build_local_requested_model_decision_input(resolved_input, requested_model);
|
||||||
input.request_auth_channel = decision.request_auth_channel.clone();
|
input.request_auth_channel = decision.request_auth_channel.clone();
|
||||||
|
input.client_surface = decision.client_surface;
|
||||||
|
input.gateway_credential_carrier = decision.gateway_credential_carrier;
|
||||||
input.client_session_affinity = client_session_affinity_from_api_request(
|
input.client_session_affinity = client_session_affinity_from_api_request(
|
||||||
spec_metadata.api_format,
|
spec_metadata.api_format,
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
@@ -128,7 +130,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
|||||||
.base_model()
|
.base_model()
|
||||||
.unwrap_or(&input.requested_model);
|
.unwrap_or(&input.requested_model);
|
||||||
let (candidates, preselection_skipped) = planner_state
|
let (candidates, preselection_skipped) = planner_state
|
||||||
.list_selectable_candidates_with_skip_reasons(
|
.list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||||
spec_metadata.api_format,
|
spec_metadata.api_format,
|
||||||
routing_model,
|
routing_model,
|
||||||
spec_metadata.require_streaming,
|
spec_metadata.require_streaming,
|
||||||
@@ -137,6 +139,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
|||||||
input.client_session_affinity.as_ref(),
|
input.client_session_affinity.as_ref(),
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
false,
|
false,
|
||||||
|
spec.operation.map(|operation| operation.as_str()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let outcome = materialize_local_execution_candidates_with_serving(
|
let outcome = materialize_local_execution_candidates_with_serving(
|
||||||
@@ -232,7 +235,7 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
|
|||||||
.base_model()
|
.base_model()
|
||||||
.unwrap_or(&input.requested_model);
|
.unwrap_or(&input.requested_model);
|
||||||
let (candidates, preselection_skipped) = planner_state
|
let (candidates, preselection_skipped) = planner_state
|
||||||
.list_selectable_candidates_with_skip_reasons(
|
.list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||||
spec_metadata.api_format,
|
spec_metadata.api_format,
|
||||||
routing_model,
|
routing_model,
|
||||||
spec_metadata.require_streaming,
|
spec_metadata.require_streaming,
|
||||||
@@ -241,6 +244,7 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
|
|||||||
input.client_session_affinity.as_ref(),
|
input.client_session_affinity.as_ref(),
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
false,
|
false,
|
||||||
|
spec.operation.map(|operation| operation.as_str()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use aether_ai_serving::{AdaptationMode, AiRequestGzipPolicy, OriginalRequestPayload};
|
||||||
|
use aether_contracts::{ExecutionResponseBodyMode, EXECUTION_RESPONSE_BODY_MODE_HEADER};
|
||||||
|
|
||||||
use crate::ai_serving::ai_local_execution_contract_for_formats;
|
use crate::ai_serving::ai_local_execution_contract_for_formats;
|
||||||
use crate::ai_serving::build_request_trace_proxy_value;
|
use crate::ai_serving::build_request_trace_proxy_value;
|
||||||
use crate::ai_serving::planner::candidate_materialization::{
|
use crate::ai_serving::planner::candidate_materialization::{
|
||||||
@@ -61,6 +64,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
let request_redacted = resolved.request_redacted;
|
||||||
|
let compatibility_edits_empty = resolved.compatibility_edits.is_empty();
|
||||||
let original_request_body_json = if resolved.request_redacted {
|
let original_request_body_json = if resolved.request_redacted {
|
||||||
Some(&resolved.provider_request_body)
|
Some(&resolved.provider_request_body)
|
||||||
} else {
|
} else {
|
||||||
@@ -82,6 +87,51 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
.clone()
|
.clone()
|
||||||
.or_else(|| resolve_transport_profile(&resolved.transport));
|
.or_else(|| resolve_transport_profile(&resolved.transport));
|
||||||
let mut extra_fields = serde_json::Map::new();
|
let mut extra_fields = serde_json::Map::new();
|
||||||
|
extra_fields.insert(
|
||||||
|
"provider_type".to_string(),
|
||||||
|
json!(resolved.transport.provider.provider_type.as_str()),
|
||||||
|
);
|
||||||
|
if let Some(operation) = spec.operation {
|
||||||
|
extra_fields.insert("api_operation".to_string(), json!(operation.as_str()));
|
||||||
|
}
|
||||||
|
if let Some(client_surface) = input.client_surface {
|
||||||
|
extra_fields.insert("client_surface".to_string(), json!(client_surface.as_str()));
|
||||||
|
}
|
||||||
|
if let Some(carrier) = input.gateway_credential_carrier {
|
||||||
|
extra_fields.insert(
|
||||||
|
"gateway_credential_carrier".to_string(),
|
||||||
|
json!(carrier.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
extra_fields.insert(
|
||||||
|
"upstream_credential_mode".to_string(),
|
||||||
|
json!(resolved.transport.key.auth_type.trim().to_ascii_lowercase()),
|
||||||
|
);
|
||||||
|
let mut adaptation_mode = if resolved.compatibility_edits.is_empty() {
|
||||||
|
AdaptationMode::NativeTransparent
|
||||||
|
} else {
|
||||||
|
AdaptationMode::SameFormatCompat
|
||||||
|
};
|
||||||
|
if crate::ai_serving::normalize_api_format_alias(&resolved.provider_api_format)
|
||||||
|
== "claude:messages"
|
||||||
|
{
|
||||||
|
let compatibility_profile =
|
||||||
|
crate::ai_serving::transport::resolve_anthropic_compatibility_profile(
|
||||||
|
&resolved.transport,
|
||||||
|
&resolved.provider_api_format,
|
||||||
|
);
|
||||||
|
extra_fields.insert(
|
||||||
|
"anthropic_compatibility_profile".to_string(),
|
||||||
|
json!(compatibility_profile.as_str()),
|
||||||
|
);
|
||||||
|
if compatibility_profile.uses_claude_code_compatibility() {
|
||||||
|
adaptation_mode = AdaptationMode::SameFormatCompat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra_fields.insert(
|
||||||
|
"adaptation_mode".to_string(),
|
||||||
|
json!(adaptation_mode.as_str()),
|
||||||
|
);
|
||||||
if let Some(proxy_value) =
|
if let Some(proxy_value) =
|
||||||
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
build_request_trace_proxy_value(Some(&resolved.transport), proxy.as_ref())
|
||||||
{
|
{
|
||||||
@@ -227,9 +277,79 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
|||||||
&mut decision,
|
&mut decision,
|
||||||
Some(transport.as_ref()),
|
Some(transport.as_ref()),
|
||||||
)?;
|
)?;
|
||||||
|
enforce_provider_api_operation_invariants(
|
||||||
|
spec.operation,
|
||||||
|
decision.provider_request_body.as_mut(),
|
||||||
|
&mut decision.provider_request_headers,
|
||||||
|
);
|
||||||
|
decision.provider_request_body_base64 = original_request_body_base64(
|
||||||
|
parts,
|
||||||
|
decision.provider_request_body.as_ref(),
|
||||||
|
adaptation_mode,
|
||||||
|
request_redacted,
|
||||||
|
compatibility_edits_empty,
|
||||||
|
decision.content_encoding.as_deref(),
|
||||||
|
decision.request_gzip.as_ref(),
|
||||||
|
);
|
||||||
|
decision
|
||||||
|
.provider_request_headers
|
||||||
|
.retain(|name, _| !name.eq_ignore_ascii_case(EXECUTION_RESPONSE_BODY_MODE_HEADER));
|
||||||
|
if !spec_metadata.require_streaming && decision.provider_request_body_base64.is_some() {
|
||||||
|
decision.provider_request_headers.insert(
|
||||||
|
EXECUTION_RESPONSE_BODY_MODE_HEADER.to_string(),
|
||||||
|
ExecutionResponseBodyMode::PreserveBytes
|
||||||
|
.as_str()
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(Some(decision))
|
Ok(Some(decision))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enforce_provider_api_operation_invariants(
|
||||||
|
operation: Option<crate::ai_serving::ApiOperation>,
|
||||||
|
provider_request_body: Option<&mut serde_json::Value>,
|
||||||
|
provider_request_headers: &mut std::collections::BTreeMap<String, String>,
|
||||||
|
) {
|
||||||
|
if operation != Some(crate::ai_serving::ApiOperation::ClaudeCountTokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(provider_request_body) = provider_request_body {
|
||||||
|
crate::ai_serving::transport::enforce_same_format_provider_api_operation_body_policy(
|
||||||
|
provider_request_body,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for header_name in ["accept", "content-type"] {
|
||||||
|
provider_request_headers.retain(|name, _| !name.eq_ignore_ascii_case(header_name));
|
||||||
|
provider_request_headers.insert(header_name.to_string(), "application/json".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn original_request_body_base64(
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
provider_request_body: Option<&serde_json::Value>,
|
||||||
|
adaptation_mode: AdaptationMode,
|
||||||
|
request_redacted: bool,
|
||||||
|
compatibility_edits_empty: bool,
|
||||||
|
content_encoding: Option<&str>,
|
||||||
|
request_gzip: Option<&AiRequestGzipPolicy>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if adaptation_mode != AdaptationMode::NativeTransparent
|
||||||
|
|| request_redacted
|
||||||
|
|| !compatibility_edits_empty
|
||||||
|
|| content_encoding.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
|| request_gzip.is_some_and(|policy| policy.enabled != Some(false))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.get::<OriginalRequestPayload>()?
|
||||||
|
.body_bytes_base64_if_unchanged(provider_request_body?)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn mark_skipped_local_same_format_provider_candidate(
|
pub(super) async fn mark_skipped_local_same_format_provider_candidate(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: &LocalSameFormatProviderDecisionInput,
|
input: &LocalSameFormatProviderDecisionInput,
|
||||||
@@ -313,3 +433,177 @@ pub(super) async fn mark_skipped_local_same_format_provider_candidate_with_failu
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
enforce_provider_api_operation_invariants, original_request_body_base64, AdaptationMode,
|
||||||
|
AiRequestGzipPolicy, OriginalRequestPayload,
|
||||||
|
};
|
||||||
|
use crate::ai_serving::ApiOperation;
|
||||||
|
|
||||||
|
fn request_parts_with_original_payload(
|
||||||
|
body_json: serde_json::Value,
|
||||||
|
body_bytes: &[u8],
|
||||||
|
) -> http::request::Parts {
|
||||||
|
let (mut parts, ()) = http::Request::new(()).into_parts();
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.insert(OriginalRequestPayload::from_parsed_json(
|
||||||
|
body_json, body_bytes,
|
||||||
|
));
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn count_tokens_invariants_win_after_provider_routing_mutations() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4",
|
||||||
|
"messages": [],
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
let mut headers = BTreeMap::from([
|
||||||
|
("Accept".to_string(), "text/event-stream".to_string()),
|
||||||
|
("Content-Type".to_string(), "text/plain".to_string()),
|
||||||
|
("x-provider-route".to_string(), "kept".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
enforce_provider_api_operation_invariants(
|
||||||
|
Some(ApiOperation::ClaudeCountTokens),
|
||||||
|
Some(&mut body),
|
||||||
|
&mut headers,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(body.get("stream").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("accept").map(String::as_str),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("content-type").map(String::as_str),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-provider-route").map(String::as_str),
|
||||||
|
Some("kept")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.keys()
|
||||||
|
.filter(|name| name.eq_ignore_ascii_case("accept"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.keys()
|
||||||
|
.filter(|name| name.eq_ignore_ascii_case("content-type"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_same_format_body_preserves_original_json_bytes() {
|
||||||
|
let raw = br#"{ "unknown": {"enabled":true}, "messages": [], "model": "claude-sonnet-4" }"#;
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(raw).expect("body should parse");
|
||||||
|
let parts = request_parts_with_original_payload(body_json.clone(), raw);
|
||||||
|
|
||||||
|
let encoded = original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("unchanged request should retain exact bytes");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.expect("body should decode"),
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_edits_or_encoding_disable_original_json_bytes() {
|
||||||
|
let raw = br#"{"model":"claude-sonnet-4","messages":[]}"#;
|
||||||
|
let body_json: serde_json::Value = serde_json::from_slice(raw).expect("body should parse");
|
||||||
|
let parts = request_parts_with_original_payload(body_json.clone(), raw);
|
||||||
|
let changed_body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"messages": []
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&changed_body),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::SameFormatCompat,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Some("gzip"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(original_request_body_base64(
|
||||||
|
&parts,
|
||||||
|
Some(&body_json),
|
||||||
|
AdaptationMode::NativeTransparent,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some(&AiRequestGzipPolicy {
|
||||||
|
enabled: Some(true),
|
||||||
|
min_bytes: Some(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trac
|
|||||||
decision_kind: "trace_candidate_metadata",
|
decision_kind: "trace_candidate_metadata",
|
||||||
report_kind: Some("trace_candidate_metadata"),
|
report_kind: Some("trace_candidate_metadata"),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
if !behavior.is_antigravity
|
if !behavior.is_antigravity
|
||||||
&& !behavior.is_claude_code
|
&& !behavior.is_claude_code_transport
|
||||||
&& !behavior.is_gemini_cli
|
&& !behavior.is_gemini_cli
|
||||||
&& !behavior.is_vertex
|
&& !behavior.is_vertex
|
||||||
&& !behavior.is_kiro
|
&& !behavior.is_kiro
|
||||||
@@ -127,6 +128,23 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
|||||||
spec: LocalSameFormatProviderSpec,
|
spec: LocalSameFormatProviderSpec,
|
||||||
) -> Result<Option<LocalSameFormatProviderCandidatePayloadParts>, GatewayError> {
|
) -> Result<Option<LocalSameFormatProviderCandidatePayloadParts>, GatewayError> {
|
||||||
let candidate = &attempt.eligible.candidate;
|
let candidate = &attempt.eligible.candidate;
|
||||||
|
if let Some(skip_reason) = same_format_provider_operation_skip_reason(
|
||||||
|
&attempt.eligible.transport,
|
||||||
|
attempt.eligible.provider_api_format.as_str(),
|
||||||
|
spec.operation,
|
||||||
|
) {
|
||||||
|
mark_skipped_local_same_format_provider_candidate(
|
||||||
|
state,
|
||||||
|
input,
|
||||||
|
trace_id,
|
||||||
|
candidate,
|
||||||
|
attempt.candidate_index,
|
||||||
|
&attempt.candidate_id,
|
||||||
|
skip_reason,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let Some(prepared) = prepare_local_same_format_provider_candidate(
|
let Some(prepared) = prepare_local_same_format_provider_candidate(
|
||||||
state,
|
state,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -364,7 +382,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
|
let mut provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
|
||||||
match build_antigravity_safe_v1internal_request(
|
match build_antigravity_safe_v1internal_request(
|
||||||
antigravity_auth,
|
antigravity_auth,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -424,6 +442,16 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
|||||||
} else {
|
} else {
|
||||||
base_provider_request_body
|
base_provider_request_body
|
||||||
};
|
};
|
||||||
|
if crate::ai_serving::transport::enforce_same_format_provider_api_operation_body_policy(
|
||||||
|
&mut provider_request_body,
|
||||||
|
spec.operation,
|
||||||
|
) {
|
||||||
|
compatibility_edits.push(SameFormatProviderCompatibilityEdit {
|
||||||
|
field: "stream".to_string(),
|
||||||
|
action: SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||||
|
detail: "removed stream field for non-streaming API operation".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let is_grok = prepared
|
let is_grok = prepared
|
||||||
.transport
|
.transport
|
||||||
@@ -490,10 +518,10 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
|||||||
original_request_body: body_json,
|
original_request_body: body_json,
|
||||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||||
behavior: prepared.behavior,
|
behavior: prepared.behavior,
|
||||||
|
api_operation: spec.operation,
|
||||||
auth_header: prepared.auth_header.as_deref(),
|
auth_header: prepared.auth_header.as_deref(),
|
||||||
auth_value: prepared.auth_value.as_deref(),
|
auth_value: prepared.auth_value.as_deref(),
|
||||||
extra_headers: &extra_headers,
|
extra_headers: &extra_headers,
|
||||||
key_fingerprint: transport.key.fingerprint.as_ref(),
|
|
||||||
kiro_auth_config: prepared.kiro_auth.as_ref().map(|auth| &auth.auth_config),
|
kiro_auth_config: prepared.kiro_auth.as_ref().map(|auth| &auth.auth_config),
|
||||||
kiro_machine_id: prepared
|
kiro_machine_id: prepared
|
||||||
.kiro_auth
|
.kiro_auth
|
||||||
@@ -564,3 +592,98 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
|
|||||||
request_redacted: redaction.redacted,
|
request_redacted: redaction.redacted,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn same_format_provider_operation_skip_reason(
|
||||||
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
|
provider_api_format: &str,
|
||||||
|
operation: Option<crate::ai_serving::ApiOperation>,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
(!crate::ai_serving::transport::transport_supports_api_operation(
|
||||||
|
transport,
|
||||||
|
provider_api_format,
|
||||||
|
operation,
|
||||||
|
))
|
||||||
|
.then_some("transport_operation_unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::same_format_provider_operation_skip_reason;
|
||||||
|
use crate::ai_serving::transport::snapshot::{
|
||||||
|
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||||
|
GatewayProviderTransportProvider,
|
||||||
|
};
|
||||||
|
use crate::ai_serving::{ApiOperation, GatewayProviderTransportSnapshot};
|
||||||
|
|
||||||
|
fn private_adapter_transport(provider_type: &str) -> GatewayProviderTransportSnapshot {
|
||||||
|
GatewayProviderTransportSnapshot {
|
||||||
|
provider: GatewayProviderTransportProvider {
|
||||||
|
id: "provider-1".to_string(),
|
||||||
|
name: provider_type.to_string(),
|
||||||
|
provider_type: provider_type.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-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
api_format: "claude:messages".to_string(),
|
||||||
|
api_family: Some("claude".to_string()),
|
||||||
|
endpoint_kind: Some("chat".to_string()),
|
||||||
|
is_active: true,
|
||||||
|
base_url: "https://private.example".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-1".to_string(),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
name: "key".to_string(),
|
||||||
|
auth_type: "oauth".to_string(),
|
||||||
|
is_active: true,
|
||||||
|
api_formats: None,
|
||||||
|
auth_type_by_format: None,
|
||||||
|
allow_auth_channel_mismatch_formats: None,
|
||||||
|
allowed_models: None,
|
||||||
|
capabilities: None,
|
||||||
|
rate_multipliers: None,
|
||||||
|
global_priority_by_format: None,
|
||||||
|
expires_at_unix_secs: None,
|
||||||
|
proxy: None,
|
||||||
|
fingerprint: None,
|
||||||
|
upstream_metadata: None,
|
||||||
|
decrypted_api_key: String::new(),
|
||||||
|
decrypted_auth_config: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_adapter_count_tokens_is_rejected_by_pre_auth_operation_gate() {
|
||||||
|
for provider_type in ["kiro", "grok"] {
|
||||||
|
let transport = private_adapter_transport(provider_type);
|
||||||
|
assert_eq!(
|
||||||
|
same_format_provider_operation_skip_reason(
|
||||||
|
&transport,
|
||||||
|
"claude:messages",
|
||||||
|
Some(ApiOperation::ClaudeCountTokens),
|
||||||
|
),
|
||||||
|
Some("transport_operation_unsupported"),
|
||||||
|
"provider_type={provider_type}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
use crate::ai_serving::planner::spec_metadata::LocalExecutionSurfaceSpecMetadata;
|
use crate::ai_serving::planner::spec_metadata::LocalExecutionSurfaceSpecMetadata;
|
||||||
use crate::ai_serving::transport::{
|
use crate::ai_serving::transport::{
|
||||||
classify_same_format_provider_request_behavior as classify_same_format_provider_request_behavior_impl,
|
classify_same_format_provider_request_behavior_for_operation as classify_same_format_provider_request_behavior_impl,
|
||||||
resolve_same_format_provider_direct_auth as resolve_same_format_provider_direct_auth_impl,
|
resolve_same_format_provider_direct_auth as resolve_same_format_provider_direct_auth_impl,
|
||||||
same_format_provider_transport_supported as same_format_provider_transport_supported_impl,
|
same_format_provider_transport_supported as same_format_provider_transport_supported_impl,
|
||||||
same_format_provider_transport_unsupported_reason as same_format_provider_transport_unsupported_reason_impl,
|
same_format_provider_transport_unsupported_reason as same_format_provider_transport_unsupported_reason_impl,
|
||||||
@@ -15,6 +15,7 @@ pub(super) fn classify_same_format_provider_request_behavior(
|
|||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
provider_api_format: &str,
|
provider_api_format: &str,
|
||||||
spec_metadata: LocalExecutionSurfaceSpecMetadata,
|
spec_metadata: LocalExecutionSurfaceSpecMetadata,
|
||||||
|
api_operation: Option<crate::ai_serving::ApiOperation>,
|
||||||
) -> SameFormatProviderRequestBehavior {
|
) -> SameFormatProviderRequestBehavior {
|
||||||
classify_same_format_provider_request_behavior_impl(
|
classify_same_format_provider_request_behavior_impl(
|
||||||
transport,
|
transport,
|
||||||
@@ -25,6 +26,7 @@ pub(super) fn classify_same_format_provider_request_behavior(
|
|||||||
.report_kind
|
.report_kind
|
||||||
.expect("same-format provider specs should declare report kind"),
|
.expect("same-format provider specs should declare report kind"),
|
||||||
},
|
},
|
||||||
|
api_operation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -59,6 +59,7 @@ pub(super) async fn prepare_local_same_format_provider_candidate(
|
|||||||
&transport,
|
&transport,
|
||||||
provider_api_format,
|
provider_api_format,
|
||||||
spec_metadata,
|
spec_metadata,
|
||||||
|
spec.operation,
|
||||||
);
|
);
|
||||||
|
|
||||||
if !same_format_provider_transport_supported(
|
if !same_format_provider_transport_supported(
|
||||||
|
|||||||
@@ -214,6 +214,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncA
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -249,6 +259,16 @@ impl LocalExecutionAttemptSource<AiStreamAttempt>
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub(crate) fn build_same_format_upstream_url(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_query: parts.uri.query(),
|
request_query: parts.uri.query(),
|
||||||
kiro_api_region: kiro_auth.map(|auth| auth.auth_config.effective_api_region()),
|
kiro_api_region: kiro_auth.map(|auth| auth.auth_config.effective_api_region()),
|
||||||
|
api_operation: spec.operation,
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::ai_serving::GatewayControlDecision;
|
use crate::ai_serving::GatewayControlDecision;
|
||||||
use crate::ai_serving::{
|
use crate::ai_serving::{
|
||||||
is_matching_stream_http_request as is_matching_stream_http_request_impl,
|
is_matching_stream_http_request as is_matching_stream_http_request_impl,
|
||||||
resolve_execution_runtime_stream_plan_kind as resolve_execution_runtime_stream_plan_kind_impl,
|
resolve_execution_runtime_stream_plan_kind_with_client_surface as resolve_execution_runtime_stream_plan_kind_impl,
|
||||||
resolve_execution_runtime_sync_plan_kind as resolve_execution_runtime_sync_plan_kind_impl,
|
resolve_execution_runtime_sync_plan_kind_with_client_surface as resolve_execution_runtime_sync_plan_kind_impl,
|
||||||
supports_stream_execution_decision_kind as supports_stream_execution_decision_kind_impl,
|
supports_stream_execution_decision_kind as supports_stream_execution_decision_kind_impl,
|
||||||
supports_sync_execution_decision_kind as supports_sync_execution_decision_kind_impl,
|
supports_sync_execution_decision_kind as supports_sync_execution_decision_kind_impl,
|
||||||
};
|
};
|
||||||
@@ -11,28 +11,34 @@ pub(crate) fn resolve_execution_runtime_stream_plan_kind(
|
|||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
resolve_execution_runtime_stream_plan_kind_impl(
|
let plan_kind = resolve_execution_runtime_stream_plan_kind_impl(
|
||||||
decision.route_class.as_deref(),
|
decision.route_class.as_deref(),
|
||||||
decision.route_family.as_deref(),
|
decision.route_family.as_deref(),
|
||||||
decision.route_kind.as_deref(),
|
decision.route_kind.as_deref(),
|
||||||
|
decision.client_surface,
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.request_auth_channel.as_deref(),
|
||||||
&parts.method,
|
&parts.method,
|
||||||
parts.uri.path(),
|
parts.uri.path(),
|
||||||
)
|
)?;
|
||||||
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, true, decision.api_operation)
|
||||||
|
.then_some(plan_kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
pub(crate) fn resolve_execution_runtime_sync_plan_kind(
|
||||||
parts: &http::request::Parts,
|
parts: &http::request::Parts,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
resolve_execution_runtime_sync_plan_kind_impl(
|
let plan_kind = resolve_execution_runtime_sync_plan_kind_impl(
|
||||||
decision.route_class.as_deref(),
|
decision.route_class.as_deref(),
|
||||||
decision.route_family.as_deref(),
|
decision.route_family.as_deref(),
|
||||||
decision.route_kind.as_deref(),
|
decision.route_kind.as_deref(),
|
||||||
|
decision.client_surface,
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.request_auth_channel.as_deref(),
|
||||||
&parts.method,
|
&parts.method,
|
||||||
parts.uri.path(),
|
parts.uri.path(),
|
||||||
)
|
)?;
|
||||||
|
crate::ai_serving::plan_kind_matches_api_operation(plan_kind, false, decision.api_operation)
|
||||||
|
.then_some(plan_kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_matching_stream_request(
|
pub(crate) fn is_matching_stream_request(
|
||||||
@@ -62,7 +68,7 @@ mod tests {
|
|||||||
resolve_execution_runtime_sync_plan_kind, supports_stream_execution_decision_kind,
|
resolve_execution_runtime_sync_plan_kind, supports_stream_execution_decision_kind,
|
||||||
supports_sync_execution_decision_kind,
|
supports_sync_execution_decision_kind,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::GatewayControlDecision;
|
use crate::ai_serving::{ApiOperation, ClientSurface, GatewayControlDecision};
|
||||||
|
|
||||||
fn sample_decision(route_family: &str, route_kind: &str) -> GatewayControlDecision {
|
fn sample_decision(route_family: &str, route_kind: &str) -> GatewayControlDecision {
|
||||||
GatewayControlDecision {
|
GatewayControlDecision {
|
||||||
@@ -71,6 +77,9 @@ mod tests {
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some(route_family.to_string()),
|
route_family: Some(route_family.to_string()),
|
||||||
route_kind: Some(route_kind.to_string()),
|
route_kind: Some(route_kind.to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_context: None,
|
auth_context: None,
|
||||||
admin_principal: None,
|
admin_principal: None,
|
||||||
@@ -121,7 +130,9 @@ mod tests {
|
|||||||
let (claude_parts, _) = claude_request.into_parts();
|
let (claude_parts, _) = claude_request.into_parts();
|
||||||
|
|
||||||
let claude_api_key = sample_decision_with_auth_channel("claude", "messages", "api_key");
|
let claude_api_key = sample_decision_with_auth_channel("claude", "messages", "api_key");
|
||||||
let claude_bearer = sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
let mut claude_bearer =
|
||||||
|
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
||||||
|
claude_bearer.client_surface = Some(ClientSurface::ClaudeCode);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_api_key),
|
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_api_key),
|
||||||
Some("claude_chat_sync")
|
Some("claude_chat_sync")
|
||||||
@@ -131,6 +142,13 @@ mod tests {
|
|||||||
Some("claude_cli_stream")
|
Some("claude_cli_stream")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let claude_sdk_bearer =
|
||||||
|
sample_decision_with_auth_channel("claude", "messages", "bearer_like");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_sync_plan_kind(&claude_parts, &claude_sdk_bearer),
|
||||||
|
Some("claude_chat_sync")
|
||||||
|
);
|
||||||
|
|
||||||
let gemini_request = Request::builder()
|
let gemini_request = Request::builder()
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.uri("/v1beta/models/gemini-2.5-pro:generateContent")
|
.uri("/v1beta/models/gemini-2.5-pro:generateContent")
|
||||||
@@ -152,6 +170,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_claude_count_tokens_as_native_sync_operation() {
|
||||||
|
let request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/v1/messages/count_tokens")
|
||||||
|
.body(())
|
||||||
|
.expect("request should build");
|
||||||
|
let (parts, _) = request.into_parts();
|
||||||
|
let mut decision = sample_decision("claude", "count_tokens");
|
||||||
|
decision.api_operation = Some(ApiOperation::ClaudeCountTokens);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||||
|
Some("claude_count_tokens_sync")
|
||||||
|
);
|
||||||
|
assert!(supports_sync_execution_decision_kind(
|
||||||
|
"claude_count_tokens_sync"
|
||||||
|
));
|
||||||
|
|
||||||
|
decision.api_operation = Some(ApiOperation::ClaudeMessagesCreate);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_sync_plan_kind(&parts, &decision),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_execution_runtime_stream_plan_kind(&parts, &decision),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_matching_uses_surface_route_logic() {
|
fn stream_matching_uses_surface_route_logic() {
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
|
|||||||
@@ -194,6 +194,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptS
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -222,6 +232,16 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAtte
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -272,6 +272,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptS
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -300,6 +310,16 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAtte
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptS
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSour
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -235,6 +245,16 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalStandardStreamAttempt
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ mod tests {
|
|||||||
auth_snapshot: sample_auth_snapshot(),
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
required_capabilities: None,
|
required_capabilities: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_contracts::RequestBody;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||||
generic_decision_missing_exact_provider_request, take_ai_decision_plan_core,
|
generic_decision_missing_exact_provider_request, resolve_ai_passthrough_sync_request_body,
|
||||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
take_ai_decision_plan_core, take_ai_upstream_auth_pair, take_non_empty_string,
|
||||||
AiStreamAttempt, AiSyncAttempt,
|
AiExecutionPlanFromDecisionParts, AiStreamAttempt, AiSyncAttempt,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::transport::{
|
use crate::ai_serving::transport::{
|
||||||
build_standard_plan_fallback_headers, StandardPlanFallbackAcceptPolicy,
|
build_standard_plan_fallback_headers, StandardPlanFallbackAcceptPolicy,
|
||||||
@@ -61,6 +59,10 @@ pub(crate) fn build_gemini_sync_plan_from_decision(
|
|||||||
&provider_request_headers,
|
&provider_request_headers,
|
||||||
&provider_request_body_value,
|
&provider_request_body_value,
|
||||||
)?;
|
)?;
|
||||||
|
let request_body = resolve_ai_passthrough_sync_request_body(
|
||||||
|
Some(provider_request_body_value),
|
||||||
|
payload.provider_request_body_base64.take(),
|
||||||
|
);
|
||||||
let stream = payload.upstream_is_stream;
|
let stream = payload.upstream_is_stream;
|
||||||
let plan = build_ai_execution_plan_from_decision(
|
let plan = build_ai_execution_plan_from_decision(
|
||||||
&mut payload,
|
&mut payload,
|
||||||
@@ -70,7 +72,7 @@ pub(crate) fn build_gemini_sync_plan_from_decision(
|
|||||||
url,
|
url,
|
||||||
headers: std::mem::take(&mut provider_request_headers),
|
headers: std::mem::take(&mut provider_request_headers),
|
||||||
content_type,
|
content_type,
|
||||||
body: RequestBody::from_json(provider_request_body_value),
|
body: request_body,
|
||||||
stream,
|
stream,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -129,6 +131,10 @@ pub(crate) fn build_gemini_stream_plan_from_decision(
|
|||||||
&provider_request_headers,
|
&provider_request_headers,
|
||||||
&provider_request_body_value,
|
&provider_request_body_value,
|
||||||
)?;
|
)?;
|
||||||
|
let request_body = resolve_ai_passthrough_sync_request_body(
|
||||||
|
Some(provider_request_body_value),
|
||||||
|
payload.provider_request_body_base64.take(),
|
||||||
|
);
|
||||||
let plan = build_ai_execution_plan_from_decision(
|
let plan = build_ai_execution_plan_from_decision(
|
||||||
&mut payload,
|
&mut payload,
|
||||||
AiExecutionPlanFromDecisionParts {
|
AiExecutionPlanFromDecisionParts {
|
||||||
@@ -137,7 +143,7 @@ pub(crate) fn build_gemini_stream_plan_from_decision(
|
|||||||
url,
|
url,
|
||||||
headers: std::mem::take(&mut provider_request_headers),
|
headers: std::mem::take(&mut provider_request_headers),
|
||||||
content_type,
|
content_type,
|
||||||
body: RequestBody::from_json(provider_request_body_value),
|
body: request_body,
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ pub(crate) fn build_standard_upstream_url(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
parts.uri.query(),
|
parts.uri.query(),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
provider_request_body,
|
provider_request_body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2199,6 +2199,8 @@ mod tests {
|
|||||||
auth_snapshot: sample_auth_snapshot(),
|
auth_snapshot: sample_auth_snapshot(),
|
||||||
required_capabilities: None,
|
required_capabilities: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
client_session_affinity: None,
|
client_session_affinity: None,
|
||||||
routing_policy: None,
|
routing_policy: None,
|
||||||
routing_trace_seed: None,
|
routing_trace_seed: None,
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiChatStreamAttem
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.prefetched_attempts
|
||||||
|
.retain(|attempt| attempt.eligible.candidate.key_id != key_id);
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.prefetched_attempts
|
||||||
|
.retain(|attempt| attempt.eligible.candidate.endpoint_id != endpoint_id);
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.prefetched_attempts
|
self.prefetched_attempts
|
||||||
.retain(|attempt| attempt.eligible.candidate.provider_id != provider_id);
|
.retain(|attempt| attempt.eligible.candidate.provider_id != provider_id);
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiChatSyncAttemptSo
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -186,6 +186,16 @@ impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAtte
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -219,6 +229,16 @@ impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiResponsesStream
|
|||||||
Ok(drained)
|
Ok(drained)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_credential(key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.candidates.skip_endpoint(endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.candidates.skip_provider(provider_id);
|
self.candidates.skip_provider(provider_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_contracts::RequestBody;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
augment_sync_report_context, build_ai_execution_plan_from_decision, take_ai_decision_plan_core,
|
augment_sync_report_context, build_ai_execution_plan_from_decision,
|
||||||
|
resolve_ai_passthrough_sync_request_body, take_ai_decision_plan_core,
|
||||||
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
take_ai_upstream_auth_pair, take_non_empty_string, AiExecutionPlanFromDecisionParts,
|
||||||
AiStreamAttempt, AiSyncAttempt,
|
AiStreamAttempt, AiSyncAttempt,
|
||||||
};
|
};
|
||||||
@@ -63,6 +62,10 @@ pub(crate) fn build_standard_sync_plan_from_decision(
|
|||||||
&provider_request_headers,
|
&provider_request_headers,
|
||||||
&provider_request_body_value,
|
&provider_request_body_value,
|
||||||
)?;
|
)?;
|
||||||
|
let request_body = resolve_ai_passthrough_sync_request_body(
|
||||||
|
Some(provider_request_body_value),
|
||||||
|
payload.provider_request_body_base64.take(),
|
||||||
|
);
|
||||||
let stream = payload.upstream_is_stream;
|
let stream = payload.upstream_is_stream;
|
||||||
let plan = build_ai_execution_plan_from_decision(
|
let plan = build_ai_execution_plan_from_decision(
|
||||||
&mut payload,
|
&mut payload,
|
||||||
@@ -72,7 +75,7 @@ pub(crate) fn build_standard_sync_plan_from_decision(
|
|||||||
url,
|
url,
|
||||||
headers: std::mem::take(&mut provider_request_headers),
|
headers: std::mem::take(&mut provider_request_headers),
|
||||||
content_type,
|
content_type,
|
||||||
body: RequestBody::from_json(provider_request_body_value),
|
body: request_body,
|
||||||
stream,
|
stream,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -146,6 +149,10 @@ pub(crate) fn build_standard_stream_plan_from_decision(
|
|||||||
&provider_request_headers,
|
&provider_request_headers,
|
||||||
&provider_request_body_value,
|
&provider_request_body_value,
|
||||||
)?;
|
)?;
|
||||||
|
let request_body = resolve_ai_passthrough_sync_request_body(
|
||||||
|
Some(provider_request_body_value),
|
||||||
|
payload.provider_request_body_base64.take(),
|
||||||
|
);
|
||||||
let stream = payload.upstream_is_stream;
|
let stream = payload.upstream_is_stream;
|
||||||
let plan = build_ai_execution_plan_from_decision(
|
let plan = build_ai_execution_plan_from_decision(
|
||||||
&mut payload,
|
&mut payload,
|
||||||
@@ -155,7 +162,7 @@ pub(crate) fn build_standard_stream_plan_from_decision(
|
|||||||
url,
|
url,
|
||||||
headers: std::mem::take(&mut provider_request_headers),
|
headers: std::mem::take(&mut provider_request_headers),
|
||||||
content_type,
|
content_type,
|
||||||
body: RequestBody::from_json(provider_request_body_value),
|
body: request_body,
|
||||||
stream,
|
stream,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -166,3 +173,88 @@ pub(crate) fn build_standard_stream_plan_from_decision(
|
|||||||
report_context,
|
report_context,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use aether_contracts::{ExecutionResponseBodyMode, EXECUTION_RESPONSE_BODY_MODE_HEADER};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
build_standard_stream_plan_from_decision, build_standard_sync_plan_from_decision,
|
||||||
|
AiExecutionDecision,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn decision_with_raw_body(upstream_is_stream: bool) -> AiExecutionDecision {
|
||||||
|
serde_json::from_value(json!({
|
||||||
|
"action": if upstream_is_stream { "stream" } else { "sync" },
|
||||||
|
"request_id": "req-raw",
|
||||||
|
"provider_id": "provider-raw",
|
||||||
|
"endpoint_id": "endpoint-raw",
|
||||||
|
"key_id": "key-raw",
|
||||||
|
"upstream_url": "https://api.anthropic.test/v1/messages",
|
||||||
|
"provider_api_format": "claude:messages",
|
||||||
|
"client_api_format": "claude:messages",
|
||||||
|
"provider_request_headers": {
|
||||||
|
"content-type": "application/json",
|
||||||
|
(EXECUTION_RESPONSE_BODY_MODE_HEADER): ExecutionResponseBodyMode::PreserveBytes.as_str()
|
||||||
|
},
|
||||||
|
"provider_request_body": {
|
||||||
|
"model": "claude-sonnet-4",
|
||||||
|
"messages": []
|
||||||
|
},
|
||||||
|
"provider_request_body_base64": "eyAibW9kZWwiOiAiY2xhdWRlLXNvbm5ldC00IiwgIm1lc3NhZ2VzIjogW10gfQ==",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"upstream_is_stream": upstream_is_stream
|
||||||
|
}))
|
||||||
|
.expect("decision should deserialize")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_parts() -> http::request::Parts {
|
||||||
|
http::Request::builder()
|
||||||
|
.uri("http://localhost/v1/messages")
|
||||||
|
.body(())
|
||||||
|
.expect("request should build")
|
||||||
|
.into_parts()
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_sync_plan_prefers_exact_request_body_bytes() {
|
||||||
|
let built = build_standard_sync_plan_from_decision(
|
||||||
|
&request_parts(),
|
||||||
|
&json!({}),
|
||||||
|
decision_with_raw_body(false),
|
||||||
|
)
|
||||||
|
.expect("plan should build")
|
||||||
|
.expect("plan should exist");
|
||||||
|
|
||||||
|
assert!(built.plan.body.json_body.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
built.plan.body.body_bytes_b64.as_deref(),
|
||||||
|
Some("eyAibW9kZWwiOiAiY2xhdWRlLXNvbm5ldC00IiwgIm1lc3NhZ2VzIjogW10gfQ==")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
built
|
||||||
|
.plan
|
||||||
|
.headers
|
||||||
|
.get(EXECUTION_RESPONSE_BODY_MODE_HEADER)
|
||||||
|
.map(String::as_str),
|
||||||
|
Some(ExecutionResponseBodyMode::PreserveBytes.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_stream_plan_prefers_exact_request_body_bytes() {
|
||||||
|
let built = build_standard_stream_plan_from_decision(
|
||||||
|
&request_parts(),
|
||||||
|
&json!({}),
|
||||||
|
decision_with_raw_body(true),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("plan should build")
|
||||||
|
.expect("plan should exist");
|
||||||
|
|
||||||
|
assert!(built.plan.body.json_body.is_none());
|
||||||
|
assert!(built.plan.body.body_bytes_b64.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ pub(crate) use aether_ai_formats::api::{
|
|||||||
request_conversion_requires_enable_flag, request_path_implies_stream_request,
|
request_conversion_requires_enable_flag, request_path_implies_stream_request,
|
||||||
resolve_claude_stream_spec, resolve_claude_sync_spec,
|
resolve_claude_stream_spec, resolve_claude_sync_spec,
|
||||||
resolve_codex_responses_model_capabilities, resolve_execution_runtime_stream_plan_kind,
|
resolve_codex_responses_model_capabilities, resolve_execution_runtime_stream_plan_kind,
|
||||||
resolve_execution_runtime_sync_plan_kind, resolve_finalize_stream_rewrite_mode,
|
resolve_execution_runtime_stream_plan_kind_with_client_surface,
|
||||||
resolve_gemini_files_stream_spec, resolve_gemini_files_sync_spec, resolve_gemini_stream_spec,
|
resolve_execution_runtime_sync_plan_kind,
|
||||||
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
|
resolve_execution_runtime_sync_plan_kind_with_client_surface,
|
||||||
|
resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec,
|
||||||
|
resolve_gemini_files_sync_spec, resolve_gemini_stream_spec, resolve_gemini_sync_spec,
|
||||||
|
resolve_local_image_stream_spec, resolve_local_image_sync_spec,
|
||||||
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
|
||||||
resolve_local_video_sync_spec, resolve_openai_chat_max_tokens,
|
resolve_local_video_sync_spec, resolve_openai_chat_max_tokens,
|
||||||
resolve_openai_embedding_sync_spec, resolve_openai_responses_stream_spec,
|
resolve_openai_embedding_sync_spec, resolve_openai_responses_stream_spec,
|
||||||
@@ -129,14 +132,15 @@ pub(crate) use aether_ai_formats::api::{
|
|||||||
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||||
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
CLAUDE_CLI_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CLI_SYNC_ERROR_REPORT_KIND,
|
||||||
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
|
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CLI_SYNC_PLAN_KIND,
|
||||||
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
|
CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND, CLAUDE_COUNT_TOKENS_SYNC_PLAN_KIND,
|
||||||
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
|
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
|
||||||
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT,
|
||||||
EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
|
CODEX_OPENAI_IMAGE_INTERNAL_MODEL, EXECUTION_RUNTIME_STREAM_ACTION,
|
||||||
EXECUTION_RUNTIME_SYNC_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
|
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
|
||||||
GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND,
|
EXECUTION_RUNTIME_SYNC_DECISION_ACTION, GEMINI_CHAT_STREAM_PLAN_KIND,
|
||||||
GEMINI_CHAT_SYNC_ERROR_REPORT_KIND, GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND,
|
GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND, GEMINI_CHAT_SYNC_ERROR_REPORT_KIND,
|
||||||
GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
|
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
|
||||||
|
GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_PLAN_KIND,
|
||||||
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND,
|
||||||
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_PLAN_KIND,
|
||||||
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME,
|
||||||
@@ -167,5 +171,28 @@ pub(crate) use aether_ai_formats::api::{
|
|||||||
pub(crate) use aether_ai_formats::{
|
pub(crate) use aether_ai_formats::{
|
||||||
api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream,
|
api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream,
|
||||||
api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format,
|
api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format,
|
||||||
is_rerank_api_format, openai_responses_request_operation,
|
is_rerank_api_format, openai_responses_request_operation, ApiOperation, ClientSurface,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub(crate) fn plan_kind_matches_api_operation(
|
||||||
|
plan_kind: &str,
|
||||||
|
require_streaming: bool,
|
||||||
|
expected_operation: Option<ApiOperation>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(expected_operation) = expected_operation else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if expected_operation == ApiOperation::OpenAiResponsesCompact {
|
||||||
|
return if require_streaming {
|
||||||
|
plan_kind == OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
|
||||||
|
} else {
|
||||||
|
plan_kind == OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let resolved_operation = if require_streaming {
|
||||||
|
resolve_local_same_format_stream_spec(plan_kind).and_then(|spec| spec.operation)
|
||||||
|
} else {
|
||||||
|
resolve_local_same_format_sync_spec(plan_kind).and_then(|spec| spec.operation)
|
||||||
|
};
|
||||||
|
resolved_operation == Some(expected_operation)
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,10 +82,11 @@ pub(crate) use aether_provider_transport::{
|
|||||||
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
build_windsurf_cascade_headers, build_windsurf_cascade_request_body,
|
||||||
build_windsurf_cascade_upstream_url, candidate_common_transport_skip_reason,
|
build_windsurf_cascade_upstream_url, candidate_common_transport_skip_reason,
|
||||||
candidate_transport_pair_skip_reason, classify_same_format_provider_request_behavior,
|
candidate_transport_pair_skip_reason, classify_same_format_provider_request_behavior,
|
||||||
ensure_upstream_auth_header, gemini_files_transport_unsupported_reason,
|
classify_same_format_provider_request_behavior_for_operation,
|
||||||
header_rules_are_locally_supported, header_rules_have_enabled_rules,
|
enforce_same_format_provider_api_operation_body_policy, ensure_upstream_auth_header,
|
||||||
is_gemini_cli_provider_transport, is_windsurf_provider_transport,
|
gemini_files_transport_unsupported_reason, header_rules_are_locally_supported,
|
||||||
local_gemini_transport_unsupported_reason_with_network,
|
header_rules_have_enabled_rules, is_gemini_cli_provider_transport,
|
||||||
|
is_windsurf_provider_transport, local_gemini_transport_unsupported_reason_with_network,
|
||||||
local_openai_chat_transport_unsupported_reason,
|
local_openai_chat_transport_unsupported_reason,
|
||||||
local_standard_transport_unsupported_reason_with_network,
|
local_standard_transport_unsupported_reason_with_network,
|
||||||
local_windsurf_request_transport_unsupported_reason_with_network,
|
local_windsurf_request_transport_unsupported_reason_with_network,
|
||||||
@@ -93,22 +94,22 @@ pub(crate) use aether_provider_transport::{
|
|||||||
request_conversion_enabled_for_transport, request_conversion_transport_supported,
|
request_conversion_enabled_for_transport, request_conversion_transport_supported,
|
||||||
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
|
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
|
||||||
request_pair_direct_auth, request_pair_transport_unsupported_reason,
|
request_pair_direct_auth, request_pair_transport_unsupported_reason,
|
||||||
resolve_gemini_cli_project_id, resolve_gemini_files_auth, resolve_grok_session_auth,
|
resolve_anthropic_compatibility_profile, resolve_gemini_cli_project_id,
|
||||||
resolve_local_gemini_cli_request_auth, resolve_openai_image_auth,
|
resolve_gemini_files_auth, resolve_grok_session_auth, resolve_local_gemini_cli_request_auth,
|
||||||
resolve_same_format_provider_direct_auth, resolve_transport_execution_timeouts,
|
resolve_openai_image_auth, resolve_same_format_provider_direct_auth,
|
||||||
resolve_transport_profile, resolve_transport_proxy_snapshot,
|
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_video_create_auth,
|
resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||||
same_format_provider_transport_supported, same_format_provider_transport_unsupported_reason,
|
resolve_video_create_auth, same_format_provider_transport_supported,
|
||||||
should_skip_upstream_passthrough_header, should_try_same_format_provider_oauth_auth,
|
same_format_provider_transport_unsupported_reason, should_skip_upstream_passthrough_header,
|
||||||
supports_local_gemini_transport_with_network,
|
should_try_same_format_provider_oauth_auth, supports_local_gemini_transport_with_network,
|
||||||
supports_local_generic_oauth_request_auth_resolution,
|
supports_local_generic_oauth_request_auth_resolution,
|
||||||
supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
|
supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
|
||||||
video_create_transport_unsupported_reason, CandidateTransportPolicyFacts,
|
transport_supports_api_operation, video_create_transport_unsupported_reason,
|
||||||
GatewayProviderTransportSnapshot, GeminiCliRequestAuth, GeminiCliRequestAuthSupport,
|
AnthropicCompatibilityProfile, CandidateTransportPolicyFacts, GatewayProviderTransportSnapshot,
|
||||||
GeminiCliRequestAuthUnsupportedReason, GeminiCliRequestEnvelopeSupport,
|
GeminiCliRequestAuth, GeminiCliRequestAuthSupport, GeminiCliRequestAuthUnsupportedReason,
|
||||||
GeminiFilesHeadersInput, GeminiFilesRequestBodyError, GeminiFilesRequestBodyParts,
|
GeminiCliRequestEnvelopeSupport, GeminiFilesHeadersInput, GeminiFilesRequestBodyError,
|
||||||
GrokHeaderInput, LocalResolvedOAuthRequestAuth, ProviderOpenAiImageHeadersInput,
|
GeminiFilesRequestBodyParts, GrokHeaderInput, LocalResolvedOAuthRequestAuth,
|
||||||
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput,
|
ProviderOpenAiImageHeadersInput, ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput,
|
||||||
SameFormatProviderCompatibilityEdit, SameFormatProviderCompatibilityEditAction,
|
SameFormatProviderCompatibilityEdit, SameFormatProviderCompatibilityEditAction,
|
||||||
SameFormatProviderFamily, SameFormatProviderHeadersInput, SameFormatProviderRequestBehavior,
|
SameFormatProviderFamily, SameFormatProviderHeadersInput, SameFormatProviderRequestBehavior,
|
||||||
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
|
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::Request;
|
||||||
|
use axum::http::{header, HeaderValue, Response, StatusCode};
|
||||||
use axum::routing::{any, post};
|
use axum::routing::{any, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use super::{aliyun, claude, doubao, gemini, jina, openai};
|
use super::{aliyun, claude, doubao, gemini, jina, openai};
|
||||||
use crate::{handlers::proxy::proxy_request, state::AppState};
|
use crate::api::response::build_local_http_error_response_with_request_path;
|
||||||
|
use crate::headers::extract_or_generate_trace_id;
|
||||||
|
use crate::{handlers::proxy::proxy_request, state::AppState, GatewayError};
|
||||||
|
|
||||||
// Router registration patterns live here so AI public ingress has a single mount registry.
|
// Router registration patterns live here so AI public ingress has a single mount registry.
|
||||||
// They intentionally stay separate from manifest-facing route inventories in constants.rs,
|
// They intentionally stay separate from manifest-facing route inventories in constants.rs,
|
||||||
@@ -11,8 +16,6 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[
|
|||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
"/v1/embeddings",
|
"/v1/embeddings",
|
||||||
"/v1/rerank",
|
"/v1/rerank",
|
||||||
"/v1/messages",
|
|
||||||
"/v1/messages/count_tokens",
|
|
||||||
"/v1/responses",
|
"/v1/responses",
|
||||||
"/v1/responses/compact",
|
"/v1/responses/compact",
|
||||||
"/v1/alpha/search",
|
"/v1/alpha/search",
|
||||||
@@ -32,6 +35,8 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[
|
|||||||
"/v1internal:streamGenerateContent",
|
"/v1internal:streamGenerateContent",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CLAUDE_POST_ROUTE_PATTERNS: &[&str] = &["/v1/messages", "/v1/messages/count_tokens"];
|
||||||
|
|
||||||
const AI_ANY_ROUTE_PATTERNS: &[&str] = &[
|
const AI_ANY_ROUTE_PATTERNS: &[&str] = &[
|
||||||
"/v1/models/{*gemini_path}",
|
"/v1/models/{*gemini_path}",
|
||||||
"/v1beta/models/{*gemini_path}",
|
"/v1beta/models/{*gemini_path}",
|
||||||
@@ -48,12 +53,33 @@ pub(crate) fn mount_ai_routes(mut router: Router<AppState>) -> Router<AppState>
|
|||||||
for path in AI_POST_ROUTE_PATTERNS {
|
for path in AI_POST_ROUTE_PATTERNS {
|
||||||
router = router.route(path, post(proxy_request));
|
router = router.route(path, post(proxy_request));
|
||||||
}
|
}
|
||||||
|
for path in CLAUDE_POST_ROUTE_PATTERNS {
|
||||||
|
router = router.route(
|
||||||
|
path,
|
||||||
|
post(proxy_request).fallback(claude_method_not_allowed),
|
||||||
|
);
|
||||||
|
}
|
||||||
for path in AI_ANY_ROUTE_PATTERNS {
|
for path in AI_ANY_ROUTE_PATTERNS {
|
||||||
router = router.route(path, any(proxy_request));
|
router = router.route(path, any(proxy_request));
|
||||||
}
|
}
|
||||||
router
|
router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn claude_method_not_allowed(request: Request) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||||
|
let mut response = build_local_http_error_response_with_request_path(
|
||||||
|
&trace_id,
|
||||||
|
None,
|
||||||
|
Some(request.uri().path()),
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
"Method not allowed",
|
||||||
|
)?;
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::ALLOW, HeaderValue::from_static("POST"));
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn public_api_format_local_path(api_format: &str) -> &'static str {
|
pub(crate) fn public_api_format_local_path(api_format: &str) -> &'static str {
|
||||||
let normalized = api_format.trim().to_ascii_lowercase();
|
let normalized = api_format.trim().to_ascii_lowercase();
|
||||||
openai::local_path(&normalized)
|
openai::local_path(&normalized)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use axum::http::Response;
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::ai_serving::{build_core_error_body_for_client_format, LocalCoreSyncErrorKind};
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::control::GatewayLocalAuthRejection;
|
use crate::control::GatewayLocalAuthRejection;
|
||||||
@@ -191,7 +192,7 @@ pub(crate) fn build_local_balance_denied_response(
|
|||||||
Some(remaining) => format!("余额不足(剩余: ${remaining:.2})"),
|
Some(remaining) => format!("余额不足(剩余: ${remaining:.2})"),
|
||||||
None => "余额不足".to_string(),
|
None => "余额不足".to_string(),
|
||||||
};
|
};
|
||||||
let payload = json!({
|
let fallback_payload = json!({
|
||||||
"error": {
|
"error": {
|
||||||
"type": "balance_exceeded",
|
"type": "balance_exceeded",
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -201,6 +202,13 @@ pub(crate) fn build_local_balance_denied_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let payload = build_local_error_payload(
|
||||||
|
control_decision,
|
||||||
|
None,
|
||||||
|
&message,
|
||||||
|
LocalCoreSyncErrorKind::RateLimit,
|
||||||
|
fallback_payload,
|
||||||
|
);
|
||||||
let body =
|
let body =
|
||||||
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
||||||
@@ -218,12 +226,20 @@ pub(crate) fn build_local_user_rpm_limited_response(
|
|||||||
control_decision: Option<&GatewayControlDecision>,
|
control_decision: Option<&GatewayControlDecision>,
|
||||||
rejection: &FrontdoorUserRpmRejection,
|
rejection: &FrontdoorUserRpmRejection,
|
||||||
) -> Result<Response<Body>, GatewayError> {
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
let payload = json!({
|
let message = "请求过于频繁,请稍后重试";
|
||||||
|
let fallback_payload = json!({
|
||||||
"error": {
|
"error": {
|
||||||
"type": "rate_limit_exceeded",
|
"type": "rate_limit_exceeded",
|
||||||
"message": "请求过于频繁,请稍后重试",
|
"message": message,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let payload = build_local_error_payload(
|
||||||
|
control_decision,
|
||||||
|
None,
|
||||||
|
message,
|
||||||
|
LocalCoreSyncErrorKind::RateLimit,
|
||||||
|
fallback_payload,
|
||||||
|
);
|
||||||
let body =
|
let body =
|
||||||
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let headers = BTreeMap::from([
|
let headers = BTreeMap::from([
|
||||||
@@ -248,12 +264,35 @@ pub(crate) fn build_local_http_error_response(
|
|||||||
status_code: StatusCode,
|
status_code: StatusCode,
|
||||||
message: &str,
|
message: &str,
|
||||||
) -> Result<Response<Body>, GatewayError> {
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
let payload = json!({
|
build_local_http_error_response_with_request_path(
|
||||||
|
trace_id,
|
||||||
|
control_decision,
|
||||||
|
None,
|
||||||
|
status_code,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_local_http_error_response_with_request_path(
|
||||||
|
trace_id: &str,
|
||||||
|
control_decision: Option<&GatewayControlDecision>,
|
||||||
|
request_path: Option<&str>,
|
||||||
|
status_code: StatusCode,
|
||||||
|
message: &str,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
let fallback_payload = json!({
|
||||||
"error": {
|
"error": {
|
||||||
"type": "http_error",
|
"type": "http_error",
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let payload = build_local_error_payload(
|
||||||
|
control_decision,
|
||||||
|
request_path,
|
||||||
|
message,
|
||||||
|
local_error_kind_for_status(status_code),
|
||||||
|
fallback_payload,
|
||||||
|
);
|
||||||
let body =
|
let body =
|
||||||
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
||||||
@@ -329,19 +368,28 @@ pub(crate) fn build_local_auth_rejection_response(
|
|||||||
pub(crate) fn build_local_overloaded_response(
|
pub(crate) fn build_local_overloaded_response(
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
control_decision: Option<&GatewayControlDecision>,
|
control_decision: Option<&GatewayControlDecision>,
|
||||||
|
request_path: Option<&str>,
|
||||||
gate: &str,
|
gate: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Response<Body>, GatewayError> {
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
let payload = json!({
|
let message = "服务繁忙,请稍后重试";
|
||||||
|
let fallback_payload = json!({
|
||||||
"error": {
|
"error": {
|
||||||
"type": "overloaded",
|
"type": "overloaded",
|
||||||
"message": "服务繁忙,请稍后重试",
|
"message": message,
|
||||||
"details": {
|
"details": {
|
||||||
"gate": gate,
|
"gate": gate,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let payload = build_local_error_payload(
|
||||||
|
control_decision,
|
||||||
|
request_path,
|
||||||
|
message,
|
||||||
|
LocalCoreSyncErrorKind::Overloaded,
|
||||||
|
fallback_payload,
|
||||||
|
);
|
||||||
let body =
|
let body =
|
||||||
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
serde_json::to_vec(&payload).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
let headers = BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
||||||
@@ -354,10 +402,65 @@ pub(crate) fn build_local_overloaded_response(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_local_error_payload(
|
||||||
|
control_decision: Option<&GatewayControlDecision>,
|
||||||
|
request_path: Option<&str>,
|
||||||
|
message: &str,
|
||||||
|
kind: LocalCoreSyncErrorKind,
|
||||||
|
fallback_payload: serde_json::Value,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
if !local_error_uses_claude_format(control_decision, request_path) {
|
||||||
|
return fallback_payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
build_core_error_body_for_client_format("claude:messages", message, None, kind)
|
||||||
|
.unwrap_or(fallback_payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_error_uses_claude_format(
|
||||||
|
control_decision: Option<&GatewayControlDecision>,
|
||||||
|
request_path: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
control_decision.is_some_and(|decision| {
|
||||||
|
decision.route_family.as_deref() == Some("claude")
|
||||||
|
|| decision
|
||||||
|
.auth_endpoint_signature
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|format| {
|
||||||
|
crate::ai_serving::normalize_api_format_alias(format)
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
})
|
||||||
|
}) || request_path.is_some_and(|path| {
|
||||||
|
matches!(
|
||||||
|
path.trim_end_matches('/'),
|
||||||
|
"/v1/messages" | "/v1/messages/count_tokens"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_error_kind_for_status(status: StatusCode) -> LocalCoreSyncErrorKind {
|
||||||
|
match status.as_u16() {
|
||||||
|
400 | 405 | 422 => LocalCoreSyncErrorKind::InvalidRequest,
|
||||||
|
401 => LocalCoreSyncErrorKind::Authentication,
|
||||||
|
403 => LocalCoreSyncErrorKind::PermissionDenied,
|
||||||
|
404 => LocalCoreSyncErrorKind::NotFound,
|
||||||
|
413 => LocalCoreSyncErrorKind::RequestTooLarge,
|
||||||
|
429 => LocalCoreSyncErrorKind::RateLimit,
|
||||||
|
503 | 529 => LocalCoreSyncErrorKind::Overloaded,
|
||||||
|
_ => LocalCoreSyncErrorKind::ServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_client_response_from_parts;
|
use super::{
|
||||||
use axum::body::Body;
|
build_client_response_from_parts, build_local_auth_rejection_response,
|
||||||
|
build_local_http_error_response_with_request_path, build_local_overloaded_response,
|
||||||
|
build_local_user_rpm_limited_response,
|
||||||
|
};
|
||||||
|
use crate::control::{GatewayControlDecision, GatewayLocalAuthRejection};
|
||||||
|
use crate::rate_limit::FrontdoorUserRpmRejection;
|
||||||
|
use axum::body::{to_bytes, Body};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -386,4 +489,96 @@ mod tests {
|
|||||||
Some("no")
|
Some("no")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_decision() -> GatewayControlDecision {
|
||||||
|
GatewayControlDecision::synthetic(
|
||||||
|
"/v1/messages",
|
||||||
|
Some("ai_public".to_string()),
|
||||||
|
Some("claude".to_string()),
|
||||||
|
Some("messages".to_string()),
|
||||||
|
Some("claude:messages".to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn response_json(response: http::Response<Body>) -> serde_json::Value {
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("response body should read");
|
||||||
|
serde_json::from_slice(&body).expect("response body should be JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn claude_local_errors_use_anthropic_envelopes() {
|
||||||
|
let decision = claude_decision();
|
||||||
|
let invalid_key = build_local_auth_rejection_response(
|
||||||
|
"trace-auth",
|
||||||
|
Some(&decision),
|
||||||
|
&GatewayLocalAuthRejection::InvalidApiKey,
|
||||||
|
)
|
||||||
|
.expect("invalid-key response should build");
|
||||||
|
let invalid_key = response_json(invalid_key).await;
|
||||||
|
assert_eq!(invalid_key["type"], "error");
|
||||||
|
assert_eq!(invalid_key["error"]["type"], "authentication_error");
|
||||||
|
|
||||||
|
let rpm = build_local_user_rpm_limited_response(
|
||||||
|
"trace-rpm",
|
||||||
|
Some(&decision),
|
||||||
|
&FrontdoorUserRpmRejection {
|
||||||
|
scope: "api_key",
|
||||||
|
limit: 1,
|
||||||
|
retry_after: 60,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("RPM response should build");
|
||||||
|
let rpm = response_json(rpm).await;
|
||||||
|
assert_eq!(rpm["type"], "error");
|
||||||
|
assert_eq!(rpm["error"]["type"], "rate_limit_error");
|
||||||
|
|
||||||
|
let overloaded = build_local_overloaded_response(
|
||||||
|
"trace-overload",
|
||||||
|
None,
|
||||||
|
Some("/v1/messages/count_tokens"),
|
||||||
|
"requests",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.expect("overload response should build");
|
||||||
|
let overloaded = response_json(overloaded).await;
|
||||||
|
assert_eq!(overloaded["type"], "error");
|
||||||
|
assert_eq!(overloaded["error"]["type"], "overloaded_error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn claude_path_shapes_pre_control_http_errors_and_413() {
|
||||||
|
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
|
||||||
|
let forbidden = build_local_http_error_response_with_request_path(
|
||||||
|
"trace-pre-control",
|
||||||
|
None,
|
||||||
|
Some(path),
|
||||||
|
http::StatusCode::FORBIDDEN,
|
||||||
|
"blocked",
|
||||||
|
)
|
||||||
|
.expect("forbidden response should build");
|
||||||
|
let forbidden = response_json(forbidden).await;
|
||||||
|
assert_eq!(forbidden["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
forbidden["error"]["type"], "permission_error",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let too_large = build_local_http_error_response_with_request_path(
|
||||||
|
"trace-too-large",
|
||||||
|
None,
|
||||||
|
Some(path),
|
||||||
|
http::StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"too large",
|
||||||
|
)
|
||||||
|
.expect("payload-too-large response should build");
|
||||||
|
let too_large = response_json(too_large).await;
|
||||||
|
assert_eq!(too_large["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
too_large["error"]["type"], "request_too_large",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ pub(super) fn extract_request_credentials(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(in crate::control) fn resolve_gateway_credential_carrier(
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
uri: &Uri,
|
||||||
|
auth_endpoint_signature: &str,
|
||||||
|
) -> Option<GatewayCredentialCarrier> {
|
||||||
|
extract_request_credentials(headers, uri, auth_endpoint_signature)
|
||||||
|
.primary
|
||||||
|
.map(|credential| match credential {
|
||||||
|
GatewayPrimaryCredential::ProviderApiKey { carrier, .. }
|
||||||
|
| GatewayPrimaryCredential::BearerToken { carrier, .. }
|
||||||
|
| GatewayPrimaryCredential::CookieHeader { carrier, .. } => carrier,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn has_trusted_gateway_marker(headers: &http::HeaderMap) -> bool {
|
fn has_trusted_gateway_marker(headers: &http::HeaderMap) -> bool {
|
||||||
header_value_str(headers, crate::constants::GATEWAY_HEADER)
|
header_value_str(headers, crate::constants::GATEWAY_HEADER)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod resolution;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub(crate) use credentials::extract_requested_model;
|
pub(crate) use credentials::extract_requested_model;
|
||||||
|
pub(super) use credentials::resolve_gateway_credential_carrier;
|
||||||
pub(crate) use gate::{
|
pub(crate) use gate::{
|
||||||
execution_plan_balance_capacity_rejection, request_model_local_rejection,
|
execution_plan_balance_capacity_rejection, request_model_local_rejection,
|
||||||
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayLocalAuthRejection,
|
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayLocalAuthRejection,
|
||||||
@@ -14,3 +15,4 @@ pub(crate) use resolution::{
|
|||||||
GatewayAdminPrincipalContext, GatewayControlAuthContext,
|
GatewayAdminPrincipalContext, GatewayControlAuthContext,
|
||||||
};
|
};
|
||||||
pub(super) use resolution::{resolve_control_decision_auth, ControlDecisionAuthResolution};
|
pub(super) use resolution::{resolve_control_decision_auth, ControlDecisionAuthResolution};
|
||||||
|
pub(crate) use types::GatewayCredentialCarrier;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) enum GatewayCredentialCarrier {
|
pub(crate) enum GatewayCredentialCarrier {
|
||||||
AuthorizationBearer,
|
AuthorizationBearer,
|
||||||
XApiKey,
|
XApiKey,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -8,6 +8,26 @@ pub(super) enum GatewayCredentialCarrier {
|
|||||||
CookieHeader,
|
CookieHeader,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl GatewayCredentialCarrier {
|
||||||
|
pub(crate) const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AuthorizationBearer => "authorization_bearer",
|
||||||
|
Self::XApiKey => "x_api_key",
|
||||||
|
Self::ApiKey => "api_key",
|
||||||
|
Self::XGoogApiKey => "x_goog_api_key",
|
||||||
|
Self::QueryKey => "query_key",
|
||||||
|
Self::CookieHeader => "cookie_header",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn request_auth_channel(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AuthorizationBearer | Self::CookieHeader => "bearer_like",
|
||||||
|
Self::XApiKey | Self::ApiKey | Self::XGoogApiKey | Self::QueryKey => "api_key",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub(super) struct GatewayTrustedAuthHeaders {
|
pub(super) struct GatewayTrustedAuthHeaders {
|
||||||
pub(super) user_id: String,
|
pub(super) user_id: String,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub(crate) use auth::{
|
|||||||
refresh_execution_runtime_auth_context, request_model_local_rejection,
|
refresh_execution_runtime_auth_context, request_model_local_rejection,
|
||||||
resolve_execution_runtime_auth_context, should_buffer_request_for_local_auth,
|
resolve_execution_runtime_auth_context, should_buffer_request_for_local_auth,
|
||||||
trusted_auth_local_rejection, GatewayAdminPrincipalContext, GatewayControlAuthContext,
|
trusted_auth_local_rejection, GatewayAdminPrincipalContext, GatewayControlAuthContext,
|
||||||
GatewayLocalAuthRejection,
|
GatewayCredentialCarrier, GatewayLocalAuthRejection,
|
||||||
};
|
};
|
||||||
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
||||||
pub(crate) use management_token_permissions::{
|
pub(crate) use management_token_permissions::{
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use super::{
|
use super::{
|
||||||
classified, classified_with_request_auth_channel, is_claude_cli_request, is_gemini_cli_request,
|
classified, classified_with_request_auth_channel, detect_claude_client_surface,
|
||||||
is_gemini_models_route, is_gemini_operation_route, ClassifiedRoute,
|
is_gemini_cli_request, is_gemini_models_route, is_gemini_operation_route, ClassifiedRoute,
|
||||||
};
|
};
|
||||||
|
use crate::ai_serving::ApiOperation;
|
||||||
|
|
||||||
pub(super) fn classify_ai_public_route(
|
pub(super) fn classify_ai_public_route(
|
||||||
method: &http::Method,
|
method: &http::Method,
|
||||||
@@ -76,27 +77,33 @@ pub(super) fn classify_ai_public_route(
|
|||||||
true,
|
true,
|
||||||
))
|
))
|
||||||
} else if method == http::Method::POST && normalized_path == "/v1/messages/count_tokens" {
|
} else if method == http::Method::POST && normalized_path == "/v1/messages/count_tokens" {
|
||||||
Some(classified(
|
let request_auth_channel = claude_request_auth_channel(headers);
|
||||||
"ai_public",
|
Some(
|
||||||
"claude",
|
classified_with_request_auth_channel(
|
||||||
"count_tokens",
|
"ai_public",
|
||||||
"claude:messages",
|
"claude",
|
||||||
false,
|
"count_tokens",
|
||||||
))
|
request_auth_channel,
|
||||||
|
"claude:messages",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.with_client_surface(detect_claude_client_surface(headers))
|
||||||
|
.with_api_operation(ApiOperation::ClaudeCountTokens),
|
||||||
|
)
|
||||||
} else if method == http::Method::POST && normalized_path == "/v1/messages" {
|
} else if method == http::Method::POST && normalized_path == "/v1/messages" {
|
||||||
let request_auth_channel = if is_claude_cli_request(headers) {
|
let request_auth_channel = claude_request_auth_channel(headers);
|
||||||
"bearer_like"
|
Some(
|
||||||
} else {
|
classified_with_request_auth_channel(
|
||||||
"api_key"
|
"ai_public",
|
||||||
};
|
"claude",
|
||||||
Some(classified_with_request_auth_channel(
|
"messages",
|
||||||
"ai_public",
|
request_auth_channel,
|
||||||
"claude",
|
"claude:messages",
|
||||||
"messages",
|
true,
|
||||||
request_auth_channel,
|
)
|
||||||
"claude:messages",
|
.with_client_surface(detect_claude_client_surface(headers))
|
||||||
true,
|
.with_api_operation(ApiOperation::ClaudeMessagesCreate),
|
||||||
))
|
)
|
||||||
} else if normalized_path.starts_with("/v1/videos") {
|
} else if normalized_path.starts_with("/v1/videos") {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"ai_public",
|
"ai_public",
|
||||||
@@ -178,6 +185,20 @@ pub(super) fn classify_ai_public_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_request_auth_channel(headers: &http::HeaderMap) -> &'static str {
|
||||||
|
if crate::headers::header_value_str(headers, "x-api-key").is_some()
|
||||||
|
|| crate::headers::header_value_str(headers, "api-key").is_some()
|
||||||
|
{
|
||||||
|
"api_key"
|
||||||
|
} else if crate::headers::header_value_str(headers, http::header::AUTHORIZATION.as_str())
|
||||||
|
.is_some_and(|value| value.trim().to_ascii_lowercase().starts_with("bearer "))
|
||||||
|
{
|
||||||
|
"bearer_like"
|
||||||
|
} else {
|
||||||
|
"api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_gemini_operation_method(method: &http::Method, normalized_path: &str) -> bool {
|
fn is_gemini_operation_method(method: &http::Method, normalized_path: &str) -> bool {
|
||||||
method == http::Method::GET
|
method == http::Method::GET
|
||||||
|| (method == http::Method::POST && normalized_path.ends_with(":cancel"))
|
|| (method == http::Method::POST && normalized_path.ends_with(":cancel"))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use axum::http::Uri;
|
use axum::http::Uri;
|
||||||
|
|
||||||
|
use crate::ai_serving::{ApiOperation, ClientSurface};
|
||||||
use crate::headers::header_value_str;
|
use crate::headers::header_value_str;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
@@ -9,7 +10,10 @@ mod internal;
|
|||||||
mod oauth;
|
mod oauth;
|
||||||
mod public_support;
|
mod public_support;
|
||||||
|
|
||||||
use super::auth::{resolve_control_decision_auth, ControlDecisionAuthResolution};
|
use super::auth::{
|
||||||
|
resolve_control_decision_auth, resolve_gateway_credential_carrier,
|
||||||
|
ControlDecisionAuthResolution, GatewayCredentialCarrier,
|
||||||
|
};
|
||||||
use super::{GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayLocalAuthRejection};
|
use super::{GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayLocalAuthRejection};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -19,6 +23,9 @@ pub(crate) struct GatewayControlDecision {
|
|||||||
pub(crate) route_class: Option<String>,
|
pub(crate) route_class: Option<String>,
|
||||||
pub(crate) route_family: Option<String>,
|
pub(crate) route_family: Option<String>,
|
||||||
pub(crate) route_kind: Option<String>,
|
pub(crate) route_kind: Option<String>,
|
||||||
|
pub(crate) client_surface: Option<ClientSurface>,
|
||||||
|
pub(crate) api_operation: Option<ApiOperation>,
|
||||||
|
pub(crate) gateway_credential_carrier: Option<GatewayCredentialCarrier>,
|
||||||
pub(crate) request_auth_channel: Option<String>,
|
pub(crate) request_auth_channel: Option<String>,
|
||||||
pub(crate) auth_endpoint_signature: Option<String>,
|
pub(crate) auth_endpoint_signature: Option<String>,
|
||||||
pub(crate) execution_runtime_candidate: bool,
|
pub(crate) execution_runtime_candidate: bool,
|
||||||
@@ -42,6 +49,9 @@ impl GatewayControlDecision {
|
|||||||
route_class,
|
route_class,
|
||||||
route_family,
|
route_family,
|
||||||
route_kind,
|
route_kind,
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature,
|
auth_endpoint_signature,
|
||||||
execution_runtime_candidate: false,
|
execution_runtime_candidate: false,
|
||||||
@@ -80,6 +90,8 @@ pub(super) struct ClassifiedRoute {
|
|||||||
route_family: &'static str,
|
route_family: &'static str,
|
||||||
route_kind: &'static str,
|
route_kind: &'static str,
|
||||||
request_auth_channel: Option<&'static str>,
|
request_auth_channel: Option<&'static str>,
|
||||||
|
client_surface: Option<ClientSurface>,
|
||||||
|
api_operation: Option<ApiOperation>,
|
||||||
auth_endpoint_signature: String,
|
auth_endpoint_signature: String,
|
||||||
execution_runtime_candidate: bool,
|
execution_runtime_candidate: bool,
|
||||||
}
|
}
|
||||||
@@ -96,6 +108,8 @@ pub(super) fn classified(
|
|||||||
route_family,
|
route_family,
|
||||||
route_kind,
|
route_kind,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
auth_endpoint_signature: auth_endpoint_signature.into(),
|
auth_endpoint_signature: auth_endpoint_signature.into(),
|
||||||
execution_runtime_candidate,
|
execution_runtime_candidate,
|
||||||
}
|
}
|
||||||
@@ -114,11 +128,25 @@ pub(super) fn classified_with_request_auth_channel(
|
|||||||
route_family,
|
route_family,
|
||||||
route_kind,
|
route_kind,
|
||||||
request_auth_channel: Some(request_auth_channel),
|
request_auth_channel: Some(request_auth_channel),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
auth_endpoint_signature: auth_endpoint_signature.into(),
|
auth_endpoint_signature: auth_endpoint_signature.into(),
|
||||||
execution_runtime_candidate,
|
execution_runtime_candidate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ClassifiedRoute {
|
||||||
|
pub(super) fn with_client_surface(mut self, client_surface: ClientSurface) -> Self {
|
||||||
|
self.client_surface = Some(client_surface);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn with_api_operation(mut self, api_operation: ApiOperation) -> Self {
|
||||||
|
self.api_operation = Some(api_operation);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ClassifiedRoute {
|
impl ClassifiedRoute {
|
||||||
fn into_decision(self, public_path: String) -> GatewayControlDecision {
|
fn into_decision(self, public_path: String) -> GatewayControlDecision {
|
||||||
GatewayControlDecision {
|
GatewayControlDecision {
|
||||||
@@ -127,6 +155,9 @@ impl ClassifiedRoute {
|
|||||||
route_class: Some(self.route_class.to_string()),
|
route_class: Some(self.route_class.to_string()),
|
||||||
route_family: Some(self.route_family.to_string()),
|
route_family: Some(self.route_family.to_string()),
|
||||||
route_kind: Some(self.route_kind.to_string()),
|
route_kind: Some(self.route_kind.to_string()),
|
||||||
|
client_surface: self.client_surface,
|
||||||
|
api_operation: self.api_operation,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: self.request_auth_channel.map(str::to_string),
|
request_auth_channel: self.request_auth_channel.map(str::to_string),
|
||||||
auth_endpoint_signature: Some(self.auth_endpoint_signature),
|
auth_endpoint_signature: Some(self.auth_endpoint_signature),
|
||||||
execution_runtime_candidate: self.execution_runtime_candidate,
|
execution_runtime_candidate: self.execution_runtime_candidate,
|
||||||
@@ -183,7 +214,17 @@ pub(crate) fn classify_control_route(
|
|||||||
.or_else(|| internal::classify_internal_route(method, &normalized_path))
|
.or_else(|| internal::classify_internal_route(method, &normalized_path))
|
||||||
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, headers))?;
|
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, headers))?;
|
||||||
|
|
||||||
Some(classified.into_decision(normalized_path))
|
let mut decision = classified.into_decision(normalized_path);
|
||||||
|
if let Some(signature) = decision.auth_endpoint_signature.as_deref() {
|
||||||
|
decision.gateway_credential_carrier =
|
||||||
|
resolve_gateway_credential_carrier(headers, uri, signature);
|
||||||
|
}
|
||||||
|
if decision.route_family.as_deref() == Some("claude") {
|
||||||
|
if let Some(carrier) = decision.gateway_credential_carrier {
|
||||||
|
decision.request_auth_channel = Some(carrier.request_auth_channel().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::HeaderMap) -> String {
|
pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::HeaderMap) -> String {
|
||||||
@@ -220,11 +261,26 @@ pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::Hea
|
|||||||
"openai:chat".to_string()
|
"openai:chat".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_claude_cli_request(headers: &http::HeaderMap) -> bool {
|
pub(super) fn detect_claude_client_surface(headers: &http::HeaderMap) -> ClientSurface {
|
||||||
let auth_header = header_value_str(headers, http::header::AUTHORIZATION.as_str())
|
let user_agent = header_value_str(headers, http::header::USER_AGENT.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
auth_header.starts_with("bearer ")
|
let x_app_is_cli = header_value_str(headers, "x-app")
|
||||||
|
.is_some_and(|value| value.trim().eq_ignore_ascii_case("cli"));
|
||||||
|
if user_agent.contains("claude-code")
|
||||||
|
|| user_agent.contains("claude-cli")
|
||||||
|
|| user_agent.contains("claude code")
|
||||||
|
|| x_app_is_cli
|
||||||
|
|| header_value_str(headers, "x-claude-code-session-id").is_some()
|
||||||
|
{
|
||||||
|
ClientSurface::ClaudeCode
|
||||||
|
} else if user_agent.contains("anthropic/")
|
||||||
|
|| header_value_str(headers, "x-stainless-lang").is_some()
|
||||||
|
{
|
||||||
|
ClientSurface::AnthropicSdk
|
||||||
|
} else {
|
||||||
|
ClientSurface::GenericCompatible
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_gemini_cli_request(headers: &http::HeaderMap) -> bool {
|
pub(super) fn is_gemini_cli_request(headers: &http::HeaderMap) -> bool {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
use aether_ai_formats::{ApiOperation, ClientSurface};
|
||||||
use http::Uri;
|
use http::Uri;
|
||||||
|
|
||||||
|
use super::super::auth::GatewayCredentialCarrier;
|
||||||
use super::{classify_control_route, headers};
|
use super::{classify_control_route, headers};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_claude_count_tokens_as_non_execution_runtime_public_route() {
|
fn classifies_claude_count_tokens_as_execution_runtime_operation() {
|
||||||
let headers = headers(&[("x-api-key", "sk-test")]);
|
let headers = headers(&[("x-api-key", "sk-test")]);
|
||||||
let uri: Uri = "/v1/messages/count_tokens"
|
let uri: Uri = "/v1/messages/count_tokens"
|
||||||
.parse()
|
.parse()
|
||||||
@@ -17,7 +19,11 @@ fn classifies_claude_count_tokens_as_non_execution_runtime_public_route() {
|
|||||||
decision.auth_endpoint_signature.as_deref(),
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
Some("claude:messages")
|
Some("claude:messages")
|
||||||
);
|
);
|
||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(decision.is_execution_runtime_candidate());
|
||||||
|
assert_eq!(
|
||||||
|
decision.api_operation,
|
||||||
|
Some(ApiOperation::ClaudeCountTokens)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -141,7 +147,7 @@ fn classifies_models_list_as_claude_when_headers_match() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_claude_messages_cli_when_bearer_without_api_key() {
|
fn bearer_auth_does_not_imply_claude_code_client_surface() {
|
||||||
let headers = headers(&[("authorization", "Bearer token-123")]);
|
let headers = headers(&[("authorization", "Bearer token-123")]);
|
||||||
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
let decision =
|
let decision =
|
||||||
@@ -149,6 +155,10 @@ fn classifies_claude_messages_cli_when_bearer_without_api_key() {
|
|||||||
|
|
||||||
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
||||||
assert_eq!(decision.route_kind.as_deref(), Some("messages"));
|
assert_eq!(decision.route_kind.as_deref(), Some("messages"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.client_surface,
|
||||||
|
Some(ClientSurface::GenericCompatible)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.request_auth_channel.as_deref(),
|
||||||
Some("bearer_like")
|
Some("bearer_like")
|
||||||
@@ -161,7 +171,7 @@ fn classifies_claude_messages_cli_when_bearer_without_api_key() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_claude_messages_cli_when_bearer_is_present_even_with_api_key() {
|
fn claude_api_key_carrier_keeps_precedence_over_bearer() {
|
||||||
let headers = headers(&[
|
let headers = headers(&[
|
||||||
("authorization", "Bearer token-123"),
|
("authorization", "Bearer token-123"),
|
||||||
("x-api-key", "sk-client"),
|
("x-api-key", "sk-client"),
|
||||||
@@ -172,9 +182,10 @@ fn classifies_claude_messages_cli_when_bearer_is_present_even_with_api_key() {
|
|||||||
|
|
||||||
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
assert_eq!(decision.route_family.as_deref(), Some("claude"));
|
||||||
assert_eq!(decision.route_kind.as_deref(), Some("messages"));
|
assert_eq!(decision.route_kind.as_deref(), Some("messages"));
|
||||||
|
assert_eq!(decision.request_auth_channel.as_deref(), Some("api_key"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decision.request_auth_channel.as_deref(),
|
decision.gateway_credential_carrier,
|
||||||
Some("bearer_like")
|
Some(GatewayCredentialCarrier::XApiKey)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decision.auth_endpoint_signature.as_deref(),
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
@@ -183,6 +194,63 @@ fn classifies_claude_messages_cli_when_bearer_is_present_even_with_api_key() {
|
|||||||
assert!(decision.is_execution_runtime_candidate());
|
assert!(decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_claude_code_independently_from_bearer_auth() {
|
||||||
|
let headers = headers(&[
|
||||||
|
("authorization", "Bearer token-123"),
|
||||||
|
("user-agent", "Claude-Code/2.1.0"),
|
||||||
|
]);
|
||||||
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.client_surface, Some(ClientSurface::ClaudeCode));
|
||||||
|
assert_eq!(
|
||||||
|
decision.gateway_credential_carrier,
|
||||||
|
Some(GatewayCredentialCarrier::AuthorizationBearer)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision.api_operation,
|
||||||
|
Some(ApiOperation::ClaudeMessagesCreate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_current_claude_cli_user_agent() {
|
||||||
|
let headers = headers(&[
|
||||||
|
("x-api-key", "sk-client"),
|
||||||
|
("user-agent", "claude-cli/2.1.161 (external, cli)"),
|
||||||
|
]);
|
||||||
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.client_surface, Some(ClientSurface::ClaudeCode));
|
||||||
|
assert_eq!(decision.request_auth_channel.as_deref(), Some("api_key"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.gateway_credential_carrier,
|
||||||
|
Some(GatewayCredentialCarrier::XApiKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_claude_code_from_explicit_x_app_signal() {
|
||||||
|
let headers = headers(&[
|
||||||
|
("x-api-key", "sk-client"),
|
||||||
|
("user-agent", "rewritten-by-proxy"),
|
||||||
|
("x-app", "cli"),
|
||||||
|
]);
|
||||||
|
let uri: Uri = "/v1/messages".parse().expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.client_surface, Some(ClientSurface::ClaudeCode));
|
||||||
|
assert_eq!(
|
||||||
|
decision.api_operation,
|
||||||
|
Some(ApiOperation::ClaudeMessagesCreate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_claude_messages_when_api_key_without_bearer() {
|
fn classifies_claude_messages_when_api_key_without_bearer() {
|
||||||
let headers = headers(&[("x-api-key", "sk-client")]);
|
let headers = headers(&[("x-api-key", "sk-client")]);
|
||||||
|
|||||||
@@ -375,6 +375,10 @@ impl<'a> PoolKeyCursor<'a> {
|
|||||||
self.group.candidate.provider_id.as_str()
|
self.group.candidate.provider_id.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn endpoint_id(&self) -> &str {
|
||||||
|
self.group.candidate.endpoint_id.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
state: PlannerAppState<'a>,
|
state: PlannerAppState<'a>,
|
||||||
group: EligibleLocalExecutionCandidate,
|
group: EligibleLocalExecutionCandidate,
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ async fn execute_grok_app_chat(
|
|||||||
|
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
if !(200..300).contains(&status_code) {
|
if !(200..300).contains(&status_code) {
|
||||||
let decoded = decode_response_body_bytes(&headers, &raw_body).unwrap_or(raw_body);
|
let decoded = decode_response_body_bytes(&headers, &raw_body)?;
|
||||||
let text = String::from_utf8_lossy(&decoded).to_string();
|
let text = String::from_utf8_lossy(decoded.as_ref()).to_string();
|
||||||
return Ok(GrokCollected {
|
return Ok(GrokCollected {
|
||||||
status_code,
|
status_code,
|
||||||
headers,
|
headers,
|
||||||
@@ -274,8 +274,8 @@ async fn execute_grok_app_chat_stream(
|
|||||||
&mut adapter,
|
&mut adapter,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let decoded = decode_response_body_bytes(&headers, &raw_body).unwrap_or(raw_body);
|
let decoded = decode_response_body_bytes(&headers, &raw_body)?;
|
||||||
let text = String::from_utf8_lossy(&decoded).to_string();
|
let text = String::from_utf8_lossy(decoded.as_ref()).to_string();
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
let collected = GrokCollected {
|
let collected = GrokCollected {
|
||||||
status_code,
|
status_code,
|
||||||
|
|||||||
@@ -42,7 +42,67 @@ pub(crate) use self::response_header_rules::{
|
|||||||
pub(crate) use crate::orchestration::{
|
pub(crate) use crate::orchestration::{
|
||||||
append_local_failover_policy_to_value, LocalFailoverAnalysis, LocalFailoverDecision,
|
append_local_failover_policy_to_value, LocalFailoverAnalysis, LocalFailoverDecision,
|
||||||
};
|
};
|
||||||
|
pub(crate) use aether_ai_serving::AdaptationMode;
|
||||||
pub(crate) use aether_ai_serving::{ConversionMode, ExecutionStrategy};
|
pub(crate) use aether_ai_serving::{ConversionMode, ExecutionStrategy};
|
||||||
|
|
||||||
|
pub(crate) fn ai_attempt_retry_scope_from_failure_disposition(
|
||||||
|
disposition: crate::orchestration::FailureDisposition,
|
||||||
|
) -> aether_ai_serving::AiAttemptRetryScope {
|
||||||
|
use crate::orchestration::{FailureRetryAction, FailureScope};
|
||||||
|
use aether_ai_serving::AiAttemptRetryScope;
|
||||||
|
|
||||||
|
match disposition.failure_scope {
|
||||||
|
FailureScope::Credential | FailureScope::CredentialModel => AiAttemptRetryScope::Credential,
|
||||||
|
FailureScope::Endpoint => AiAttemptRetryScope::Endpoint,
|
||||||
|
FailureScope::Provider => AiAttemptRetryScope::Provider,
|
||||||
|
FailureScope::None => match disposition.retry_action {
|
||||||
|
FailureRetryAction::NextCredential => AiAttemptRetryScope::Credential,
|
||||||
|
FailureRetryAction::NextEndpoint => AiAttemptRetryScope::Endpoint,
|
||||||
|
FailureRetryAction::Stop
|
||||||
|
| FailureRetryAction::SameCredential
|
||||||
|
| FailureRetryAction::NextCandidate => AiAttemptRetryScope::Candidate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod retry_scope_tests {
|
||||||
|
use aether_ai_serving::AiAttemptRetryScope;
|
||||||
|
|
||||||
|
use super::ai_attempt_retry_scope_from_failure_disposition;
|
||||||
|
use crate::orchestration::{classify_failure_disposition, LocalFailoverClassification};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_failure_scope_survives_runtime_mapping() {
|
||||||
|
let retry_scope = |status_code| {
|
||||||
|
ai_attempt_retry_scope_from_failure_disposition(classify_failure_disposition(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
status_code,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(retry_scope(429), AiAttemptRetryScope::Credential);
|
||||||
|
assert_eq!(retry_scope(500), AiAttemptRetryScope::Endpoint);
|
||||||
|
assert_eq!(retry_scope(529), AiAttemptRetryScope::Provider);
|
||||||
|
assert_eq!(retry_scope(400), AiAttemptRetryScope::Candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_anthropic_retry_keeps_existing_candidate_order() {
|
||||||
|
let disposition = classify_failure_disposition(
|
||||||
|
"openai:chat",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ai_attempt_retry_scope_from_failure_disposition(disposition),
|
||||||
|
AiAttemptRetryScope::Candidate
|
||||||
|
);
|
||||||
|
assert!(!disposition.preserve_upstream_error);
|
||||||
|
}
|
||||||
|
}
|
||||||
pub use server::{
|
pub use server::{
|
||||||
build_execution_runtime_router, build_execution_runtime_router_with_request_concurrency_limit,
|
build_execution_runtime_router, build_execution_runtime_router_with_request_concurrency_limit,
|
||||||
build_execution_runtime_router_with_request_gates, serve_execution_runtime_tcp,
|
build_execution_runtime_router_with_request_gates, serve_execution_runtime_tcp,
|
||||||
@@ -57,12 +117,14 @@ pub async fn prewarm_direct_h2c_sender_cache_from_env_for_startup(
|
|||||||
.map_err(|err| err.to_string())
|
.map_err(|err| err.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) use stream::execute_execution_runtime_stream;
|
pub(crate) use stream::{
|
||||||
|
execute_execution_runtime_stream, execute_execution_runtime_stream_with_retry_scope,
|
||||||
|
};
|
||||||
pub(crate) use stream_pump::build_direct_execution_frame_stream;
|
pub(crate) use stream_pump::build_direct_execution_frame_stream;
|
||||||
pub(crate) use sync::{
|
pub(crate) use sync::{
|
||||||
execute_execution_runtime_sync, maybe_build_local_sync_finalize_response,
|
execute_execution_runtime_sync, execute_execution_runtime_sync_with_retry_scope,
|
||||||
maybe_build_local_video_error_response, maybe_build_local_video_success_outcome,
|
maybe_build_local_sync_finalize_response, maybe_build_local_video_error_response,
|
||||||
resolve_local_sync_error_background_report_kind,
|
maybe_build_local_video_success_outcome, resolve_local_sync_error_background_report_kind,
|
||||||
resolve_local_sync_success_background_report_kind, LocalVideoSyncSuccessBuild,
|
resolve_local_sync_success_background_report_kind, LocalVideoSyncSuccessBuild,
|
||||||
LocalVideoSyncSuccessOutcome,
|
LocalVideoSyncSuccessOutcome,
|
||||||
};
|
};
|
||||||
@@ -220,6 +282,16 @@ pub(crate) fn append_execution_contract_fields(
|
|||||||
"provider_contract".to_string(),
|
"provider_contract".to_string(),
|
||||||
Value::String(provider_contract.to_string()),
|
Value::String(provider_contract.to_string()),
|
||||||
);
|
);
|
||||||
|
let default_adaptation_mode = if execution_strategy == ExecutionStrategy::LocalCrossFormat
|
||||||
|
|| conversion_mode != ConversionMode::None
|
||||||
|
{
|
||||||
|
AdaptationMode::CrossFormat
|
||||||
|
} else {
|
||||||
|
AdaptationMode::NativeTransparent
|
||||||
|
};
|
||||||
|
object
|
||||||
|
.entry("adaptation_mode".to_string())
|
||||||
|
.or_insert_with(|| Value::String(default_adaptation_mode.as_str().to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn append_execution_contract_fields_to_value(
|
pub(crate) fn append_execution_contract_fields_to_value(
|
||||||
@@ -263,6 +335,7 @@ mod tests {
|
|||||||
assert_eq!(value["conversion_mode"], "bidirectional");
|
assert_eq!(value["conversion_mode"], "bidirectional");
|
||||||
assert_eq!(value["client_contract"], "openai:chat");
|
assert_eq!(value["client_contract"], "openai:chat");
|
||||||
assert_eq!(value["provider_contract"], "gemini:generate_content");
|
assert_eq!(value["provider_contract"], "gemini:generate_content");
|
||||||
|
assert_eq!(value["adaptation_mode"], "cross_format");
|
||||||
assert_eq!(value["provider_api_format"], "gemini:generate_content");
|
assert_eq!(value["provider_api_format"], "gemini:generate_content");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use aether_contracts::ExecutionPlan;
|
use aether_contracts::ExecutionPlan;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::orchestration::{
|
||||||
|
oauth_status_may_be_invalid as status_may_be_oauth_invalid,
|
||||||
|
oauth_status_proves_access_token_invalid as status_proves_access_token_invalid,
|
||||||
|
};
|
||||||
use crate::state::AgentIdentityAuthConfigFence;
|
use crate::state::AgentIdentityAuthConfigFence;
|
||||||
use crate::{provider_transport::LocalOAuthRefreshError, AppState};
|
use crate::{provider_transport::LocalOAuthRefreshError, AppState};
|
||||||
|
|
||||||
@@ -70,17 +74,17 @@ pub(crate) async fn refresh_oauth_plan_auth_for_retry(
|
|||||||
// A bearer-token response cannot authorize refreshing an Agent Identity
|
// A bearer-token response cannot authorize refreshing an Agent Identity
|
||||||
// installed under the same key id while the request was in flight.
|
// installed under the same key id while the request was in flight.
|
||||||
return false;
|
return false;
|
||||||
} else if transport
|
} else if aether_provider_transport::supports_local_generic_oauth_request_auth_resolution(
|
||||||
.provider
|
&transport,
|
||||||
.provider_type
|
) {
|
||||||
.trim()
|
if let Some(current_authorization) = generic_oauth_transport_authorization(&transport) {
|
||||||
.eq_ignore_ascii_case("codex")
|
if !request_authorization.is_some_and(|authorization| {
|
||||||
&& transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
authorizations_use_same_access_token(authorization, ¤t_authorization)
|
||||||
&& !request_authorization.is_some_and(|authorization| {
|
}) {
|
||||||
bearer_authorization_matches_transport(authorization, &transport)
|
replace_execution_plan_authorization(plan, current_authorization);
|
||||||
})
|
return true;
|
||||||
{
|
}
|
||||||
return false;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if transport.key.decrypted_auth_config.is_none()
|
if transport.key.decrypted_auth_config.is_none()
|
||||||
@@ -164,63 +168,31 @@ fn execution_plan_authorization(plan: &ExecutionPlan) -> Option<&str> {
|
|||||||
.map(|(_, value)| value.as_str())
|
.map(|(_, value)| value.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bearer_authorization_matches_transport(
|
fn generic_oauth_transport_authorization(
|
||||||
authorization: &str,
|
|
||||||
transport: &aether_provider_transport::GatewayProviderTransportSnapshot,
|
transport: &aether_provider_transport::GatewayProviderTransportSnapshot,
|
||||||
) -> bool {
|
) -> Option<String> {
|
||||||
let current_token = transport.key.decrypted_api_key.trim();
|
aether_provider_transport::resolve_local_generic_oauth_transport_authorization(transport)
|
||||||
!current_token.is_empty()
|
|
||||||
&& authorization
|
|
||||||
.trim()
|
|
||||||
.strip_prefix("Bearer ")
|
|
||||||
.map(str::trim)
|
|
||||||
.is_some_and(|token| token == current_token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_may_be_oauth_invalid(status_code: u16, response_text: Option<&str>) -> bool {
|
fn authorizations_use_same_access_token(left: &str, right: &str) -> bool {
|
||||||
if status_code == 401 {
|
match (bearer_access_token(left), bearer_access_token(right)) {
|
||||||
return true;
|
(Some(left), Some(right)) => left == right,
|
||||||
|
_ => left.trim() == right.trim(),
|
||||||
}
|
}
|
||||||
if status_code != 403 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(response_text) = response_text else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let response_text = response_text.to_ascii_lowercase();
|
|
||||||
["oauth", "token", "auth", "credential", "expired"]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| response_text.contains(needle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_proves_access_token_invalid(status_code: u16, response_text: Option<&str>) -> bool {
|
fn bearer_access_token(authorization: &str) -> Option<&str> {
|
||||||
if status_code == 401 {
|
let mut parts = authorization.split_ascii_whitespace();
|
||||||
return true;
|
let scheme = parts.next()?;
|
||||||
}
|
let token = parts.next()?;
|
||||||
if status_code != 403 {
|
(scheme.eq_ignore_ascii_case("bearer") && parts.next().is_none()).then_some(token)
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let Some(response_text) = response_text else {
|
fn replace_execution_plan_authorization(plan: &mut ExecutionPlan, authorization: String) {
|
||||||
return false;
|
plan.headers
|
||||||
};
|
.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
|
||||||
let response_text = response_text.to_ascii_lowercase();
|
plan.headers
|
||||||
[
|
.insert("authorization".to_string(), authorization);
|
||||||
"oauth_token_invalid",
|
|
||||||
"invalid_token",
|
|
||||||
"invalid access token",
|
|
||||||
"access token invalid",
|
|
||||||
"access token expired",
|
|
||||||
"expired access token",
|
|
||||||
"authentication token has been invalidated",
|
|
||||||
"token has been invalidated",
|
|
||||||
"personal access token owner is inactive",
|
|
||||||
"biscuit_baker_service_auth_credential_error_status",
|
|
||||||
"security token included in the request is expired",
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|needle| response_text.contains(needle))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -230,14 +202,15 @@ mod tests {
|
|||||||
status_proves_access_token_invalid,
|
status_proves_access_token_invalid,
|
||||||
};
|
};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
use aether_contracts::{ExecutionPlan, RequestBody};
|
||||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
|
||||||
StoredProviderCatalogProvider,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
};
|
};
|
||||||
use axum::routing::post;
|
use axum::routing::post;
|
||||||
use axum::{extract::Request, Json, Router};
|
use axum::{extract::Request, Json, Router};
|
||||||
@@ -252,8 +225,65 @@ mod tests {
|
|||||||
403,
|
403,
|
||||||
Some("The security token included in the request is expired")
|
Some("The security token included in the request is expired")
|
||||||
));
|
));
|
||||||
assert!(status_may_be_oauth_invalid(403, None));
|
assert!(status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some("oauth_token_invalid")
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(403, None));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"type":"error","error":{"type":"permission_error","message":"this token is not authorized for the workspace"}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"error":{"type":"permission_error","message":"the authentication token has been invalidated for this workspace"}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert!(status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"type":"error","error":{"type":"authentication_error","message":"credential expired"}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert!(status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(r#"{"error":{"type":"oauth_token_invalid","message":"sign in again"}}"#)
|
||||||
|
));
|
||||||
|
assert!(status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"error":{"code":"biscuit_baker_service_auth_credential_error_status","message":"Personal access token owner is inactive."}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"error":{"type":"invalid_request_error","message":"Your authentication token has been invalidated. Please try signing in again."}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some(
|
||||||
|
r#"{"error":{"type":"invalid_request_error","message":"invalid request: token budget is invalid"}}"#
|
||||||
|
)
|
||||||
|
));
|
||||||
assert!(!status_may_be_oauth_invalid(403, Some("quota exceeded")));
|
assert!(!status_may_be_oauth_invalid(403, Some("quota exceeded")));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some("invalid request: max token budget is invalid")
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some("invalid_token_budget")
|
||||||
|
));
|
||||||
|
assert!(!status_may_be_oauth_invalid(403, Some("not authorized")));
|
||||||
|
assert!(!status_may_be_oauth_invalid(
|
||||||
|
403,
|
||||||
|
Some("authorization denied")
|
||||||
|
));
|
||||||
assert!(!status_may_be_oauth_invalid(429, Some("token bucket")));
|
assert!(!status_may_be_oauth_invalid(429, Some("token bucket")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +312,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn auto_removes_request_proven_oauth_failure_after_terminal_refresh_failure() {
|
async fn retains_codex_key_after_request_proven_terminal_refresh_failure() {
|
||||||
let token_hits = Arc::new(Mutex::new(0usize));
|
let token_hits = Arc::new(Mutex::new(0usize));
|
||||||
let token_hits_clone = Arc::clone(&token_hits);
|
let token_hits_clone = Arc::clone(&token_hits);
|
||||||
let token_server = Router::new().route(
|
let token_server = Router::new().route(
|
||||||
@@ -437,7 +467,233 @@ mod tests {
|
|||||||
.list_keys_by_ids(&["key-codex-oauth-retry".to_string()])
|
.list_keys_by_ids(&["key-codex-oauth-retry".to_string()])
|
||||||
.await
|
.await
|
||||||
.expect("keys should read");
|
.expect("keys should read");
|
||||||
assert!(keys.is_empty());
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert!(keys[0].oauth_invalid_at_unix_secs.is_some());
|
||||||
|
assert!(keys[0]
|
||||||
|
.oauth_invalid_reason
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|reason| reason.contains("[REFRESH_FAILED]")
|
||||||
|
&& reason.contains("Token 续期失败 (401)")));
|
||||||
|
|
||||||
|
token_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_claude_code_request_reuses_rotated_access_token_without_second_refresh() {
|
||||||
|
let refresh_hits = Arc::new(AtomicUsize::new(0));
|
||||||
|
let refresh_hits_for_server = Arc::clone(&refresh_hits);
|
||||||
|
let token_server = Router::new().route(
|
||||||
|
"/oauth/token",
|
||||||
|
post(move |_request: Request| {
|
||||||
|
let hits = Arc::clone(&refresh_hits_for_server);
|
||||||
|
async move {
|
||||||
|
hits.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Json(json!({
|
||||||
|
"access_token": "fresh-claude-access-token",
|
||||||
|
"refresh_token": "fresh-claude-refresh-token",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"token_type": "Bearer"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider = StoredProviderCatalogProvider::new(
|
||||||
|
"provider-claude-code".to_string(),
|
||||||
|
"Claude Code".to_string(),
|
||||||
|
Some("https://api.anthropic.com".to_string()),
|
||||||
|
"claude_code".to_string(),
|
||||||
|
)
|
||||||
|
.expect("provider should build");
|
||||||
|
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||||
|
"endpoint-claude-code".to_string(),
|
||||||
|
"provider-claude-code".to_string(),
|
||||||
|
"claude:messages".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("endpoint should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
"https://api.anthropic.com".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("endpoint transport should build");
|
||||||
|
let encrypted_api_key = encrypt_python_fernet_plaintext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
"stale-claude-access-token",
|
||||||
|
)
|
||||||
|
.expect("api key ciphertext should build");
|
||||||
|
let encrypted_auth_config = encrypt_python_fernet_plaintext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
r#"{"provider_type":"claude_code","access_token":"stale-claude-access-token","refresh_token":"stale-claude-refresh-token","expires_at":4102444800}"#,
|
||||||
|
)
|
||||||
|
.expect("auth config ciphertext should build");
|
||||||
|
let mut key = StoredProviderCatalogKey::new(
|
||||||
|
"key-claude-code".to_string(),
|
||||||
|
"provider-claude-code".to_string(),
|
||||||
|
"Claude OAuth".to_string(),
|
||||||
|
"oauth".to_string(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("key should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
Some(json!(["claude:messages"])),
|
||||||
|
encrypted_api_key,
|
||||||
|
Some(encrypted_auth_config),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("key transport should build");
|
||||||
|
key.expires_at_unix_secs = Some(4_102_444_800);
|
||||||
|
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![endpoint],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let (token_url, token_handle) = start_test_server(token_server).await;
|
||||||
|
let oauth_refresh =
|
||||||
|
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
||||||
|
Arc::new(
|
||||||
|
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
||||||
|
.with_token_url_for_tests(
|
||||||
|
"claude_code",
|
||||||
|
format!("{token_url}/oauth/token"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let state = crate::AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
crate::data::GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||||
|
provider_catalog_repository.clone(),
|
||||||
|
)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
)
|
||||||
|
.with_oauth_refresh_coordinator_for_tests(oauth_refresh);
|
||||||
|
let stale_transport = state
|
||||||
|
.read_provider_transport_snapshot(
|
||||||
|
"provider-claude-code",
|
||||||
|
"endpoint-claude-code",
|
||||||
|
"key-claude-code",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stale transport should load")
|
||||||
|
.expect("stale transport should exist");
|
||||||
|
let stale_plan = ExecutionPlan {
|
||||||
|
request_id: "req-claude-oauth-fence".to_string(),
|
||||||
|
candidate_id: None,
|
||||||
|
provider_name: Some("claude_code".to_string()),
|
||||||
|
provider_id: "provider-claude-code".to_string(),
|
||||||
|
endpoint_id: "endpoint-claude-code".to_string(),
|
||||||
|
key_id: "key-claude-code".to_string(),
|
||||||
|
method: "POST".to_string(),
|
||||||
|
url: "https://api.anthropic.com/v1/messages".to_string(),
|
||||||
|
headers: BTreeMap::from([(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer stale-claude-access-token".to_string(),
|
||||||
|
)]),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
content_encoding: None,
|
||||||
|
body: RequestBody::from_json(json!({"model": "claude-sonnet-4-5"})),
|
||||||
|
stream: false,
|
||||||
|
client_api_format: "claude:messages".to_string(),
|
||||||
|
provider_api_format: "claude:messages".to_string(),
|
||||||
|
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||||
|
proxy: None,
|
||||||
|
transport_profile: None,
|
||||||
|
timeouts: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut first_plan = stale_plan.clone();
|
||||||
|
assert!(
|
||||||
|
refresh_oauth_plan_auth_for_retry(
|
||||||
|
&state,
|
||||||
|
&mut first_plan,
|
||||||
|
401,
|
||||||
|
Some(r#"{"error":"invalid_token"}"#),
|
||||||
|
"trace-claude-oauth-fence-first",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
first_plan.headers.get("authorization").map(String::as_str),
|
||||||
|
Some("Bearer fresh-claude-access-token")
|
||||||
|
);
|
||||||
|
assert_eq!(refresh_hits.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let stale_force_result = state
|
||||||
|
.force_local_oauth_refresh_entry(&stale_transport)
|
||||||
|
.await
|
||||||
|
.expect("stale force should reuse the persisted winner")
|
||||||
|
.expect("stale force should return the winner entry");
|
||||||
|
assert_eq!(
|
||||||
|
stale_force_result.auth_header_value,
|
||||||
|
"Bearer fresh-claude-access-token"
|
||||||
|
);
|
||||||
|
assert_eq!(refresh_hits.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let mut stale_in_flight_plan = stale_plan;
|
||||||
|
assert!(
|
||||||
|
refresh_oauth_plan_auth_for_retry(
|
||||||
|
&state,
|
||||||
|
&mut stale_in_flight_plan,
|
||||||
|
401,
|
||||||
|
Some(r#"{"error":"invalid_token"}"#),
|
||||||
|
"trace-claude-oauth-fence-stale",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stale_in_flight_plan
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("Bearer fresh-claude-access-token")
|
||||||
|
);
|
||||||
|
assert_eq!(refresh_hits.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let mut admin_replacement = provider_catalog_repository
|
||||||
|
.list_keys_by_ids(&["key-claude-code".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("Claude key should load")
|
||||||
|
.pop()
|
||||||
|
.expect("Claude key should exist");
|
||||||
|
admin_replacement.encrypted_api_key = Some(
|
||||||
|
encrypt_python_fernet_plaintext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
"admin-claude-access-token",
|
||||||
|
)
|
||||||
|
.expect("admin access token should encrypt"),
|
||||||
|
);
|
||||||
|
admin_replacement.expires_at_unix_secs = Some(4_102_444_800);
|
||||||
|
provider_catalog_repository
|
||||||
|
.update_key(&admin_replacement)
|
||||||
|
.await
|
||||||
|
.expect("admin replacement should persist");
|
||||||
|
|
||||||
|
let admin_result = state
|
||||||
|
.force_local_oauth_refresh_entry(&stale_transport)
|
||||||
|
.await
|
||||||
|
.expect("stale force should reuse the admin replacement")
|
||||||
|
.expect("admin replacement should resolve");
|
||||||
|
assert_eq!(
|
||||||
|
admin_result.auth_header_value,
|
||||||
|
"Bearer admin-claude-access-token"
|
||||||
|
);
|
||||||
|
assert_eq!(refresh_hits.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
token_handle.abort();
|
token_handle.abort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,6 +375,8 @@ impl IntoResponse for ExecutionRuntimeAppError {
|
|||||||
| ExecutionRuntimeTransportError::BrowserClientBuild(_)
|
| ExecutionRuntimeTransportError::BrowserClientBuild(_)
|
||||||
| ExecutionRuntimeTransportError::BrowserBody(_)
|
| ExecutionRuntimeTransportError::BrowserBody(_)
|
||||||
| ExecutionRuntimeTransportError::UpstreamRequest(_)
|
| ExecutionRuntimeTransportError::UpstreamRequest(_)
|
||||||
|
| ExecutionRuntimeTransportError::UpstreamResponseTooLarge { .. }
|
||||||
|
| ExecutionRuntimeTransportError::UpstreamResponseDecode { .. }
|
||||||
| ExecutionRuntimeTransportError::RelayError(_)
|
| ExecutionRuntimeTransportError::RelayError(_)
|
||||||
| ExecutionRuntimeTransportError::InvalidJson(_),
|
| ExecutionRuntimeTransportError::InvalidJson(_),
|
||||||
) => StatusCode::BAD_GATEWAY,
|
) => StatusCode::BAD_GATEWAY,
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::execution_runtime::MAX_STREAM_PREFETCH_BYTES;
|
||||||
|
|
||||||
|
const ANTHROPIC_PRECOMMIT_MAX_WAIT: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum StreamCommitPolicy {
|
||||||
|
OnResponseHeaders,
|
||||||
|
OnFirstClassifiedBody,
|
||||||
|
OnFirstAnthropicSemanticEvent {
|
||||||
|
max_bytes: usize,
|
||||||
|
max_wait: Duration,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamCommitPolicy {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn for_response(
|
||||||
|
has_direct_finalize: bool,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
provider_api_format: &str,
|
||||||
|
client_api_format: &str,
|
||||||
|
has_private_stream_normalizer: bool,
|
||||||
|
has_local_stream_rewriter: bool,
|
||||||
|
force_prefetch: bool,
|
||||||
|
) -> Self {
|
||||||
|
if !has_direct_finalize {
|
||||||
|
return Self::OnFirstClassifiedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
if force_prefetch {
|
||||||
|
return Self::OnFirstClassifiedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_type = content_type
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if content_type.contains("text/event-stream") {
|
||||||
|
if provider_api_format.eq_ignore_ascii_case("claude:messages")
|
||||||
|
&& provider_api_format.eq_ignore_ascii_case(client_api_format)
|
||||||
|
&& !has_private_stream_normalizer
|
||||||
|
&& !has_local_stream_rewriter
|
||||||
|
{
|
||||||
|
return Self::OnFirstAnthropicSemanticEvent {
|
||||||
|
max_bytes: MAX_STREAM_PREFETCH_BYTES,
|
||||||
|
max_wait: ANTHROPIC_PRECOMMIT_MAX_WAIT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Self::OnResponseHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_private_stream_normalizer || has_local_stream_rewriter {
|
||||||
|
return Self::OnFirstClassifiedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !provider_api_format.eq_ignore_ascii_case(client_api_format) {
|
||||||
|
return Self::OnFirstClassifiedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type.is_empty() {
|
||||||
|
return Self::OnResponseHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_type.contains("json") || content_type.ends_with("+json") {
|
||||||
|
Self::OnFirstClassifiedBody
|
||||||
|
} else {
|
||||||
|
Self::OnResponseHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn commits_on_response_headers(self) -> bool {
|
||||||
|
matches!(self, Self::OnResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn requires_bounded_frame_wait(self) -> bool {
|
||||||
|
matches!(self, Self::OnFirstAnthropicSemanticEvent { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn max_precommit_wait(self) -> Option<Duration> {
|
||||||
|
match self {
|
||||||
|
Self::OnFirstAnthropicSemanticEvent { max_wait, .. } => Some(max_wait),
|
||||||
|
Self::OnResponseHeaders | Self::OnFirstClassifiedBody => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn is_native_anthropic(self) -> bool {
|
||||||
|
matches!(self, Self::OnFirstAnthropicSemanticEvent { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) enum StreamCommitState {
|
||||||
|
Uncommitted,
|
||||||
|
Committed,
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(super) enum StreamPrecommitObservation {
|
||||||
|
Pending,
|
||||||
|
Commit,
|
||||||
|
UpstreamError { status_code: u16, body_json: Value },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct StreamCommitGate {
|
||||||
|
policy: StreamCommitPolicy,
|
||||||
|
state: StreamCommitState,
|
||||||
|
observed_bytes: usize,
|
||||||
|
anthropic: AnthropicSsePrecommitInspector,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamCommitGate {
|
||||||
|
pub(super) fn new(policy: StreamCommitPolicy) -> Self {
|
||||||
|
let state = if policy.commits_on_response_headers() {
|
||||||
|
StreamCommitState::Committed
|
||||||
|
} else {
|
||||||
|
StreamCommitState::Uncommitted
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
policy,
|
||||||
|
state,
|
||||||
|
observed_bytes: 0,
|
||||||
|
anthropic: AnthropicSsePrecommitInspector::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn state(&self) -> StreamCommitState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn is_uncommitted(&self) -> bool {
|
||||||
|
matches!(self.state, StreamCommitState::Uncommitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn observe_provider_bytes(&mut self, chunk: &[u8]) -> StreamPrecommitObservation {
|
||||||
|
if self.state != StreamCommitState::Uncommitted {
|
||||||
|
return StreamPrecommitObservation::Commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
let StreamCommitPolicy::OnFirstAnthropicSemanticEvent { max_bytes, .. } = self.policy
|
||||||
|
else {
|
||||||
|
return StreamPrecommitObservation::Pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.observed_bytes = self.observed_bytes.saturating_add(chunk.len());
|
||||||
|
match self.anthropic.observe(chunk, max_bytes) {
|
||||||
|
AnthropicSseObservation::Pending => {}
|
||||||
|
AnthropicSseObservation::SemanticEvent => {
|
||||||
|
self.state = StreamCommitState::Committed;
|
||||||
|
return StreamPrecommitObservation::Commit;
|
||||||
|
}
|
||||||
|
AnthropicSseObservation::Error(body_json) => {
|
||||||
|
self.state = StreamCommitState::Terminal;
|
||||||
|
return StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: anthropic_error_status_code(&body_json),
|
||||||
|
body_json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.observed_bytes >= max_bytes {
|
||||||
|
self.commit();
|
||||||
|
StreamPrecommitObservation::Commit
|
||||||
|
} else {
|
||||||
|
StreamPrecommitObservation::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn commit(&mut self) {
|
||||||
|
if self.state == StreamCommitState::Uncommitted {
|
||||||
|
self.state = StreamCommitState::Committed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AnthropicSseObservation {
|
||||||
|
Pending,
|
||||||
|
SemanticEvent,
|
||||||
|
Error(Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct AnthropicSsePrecommitInspector {
|
||||||
|
buffered: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicSsePrecommitInspector {
|
||||||
|
fn observe(&mut self, chunk: &[u8], max_bytes: usize) -> AnthropicSseObservation {
|
||||||
|
let remaining = max_bytes.saturating_sub(self.buffered.len());
|
||||||
|
let truncated = chunk.len() > remaining;
|
||||||
|
self.buffered
|
||||||
|
.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
|
||||||
|
|
||||||
|
while let Some((record_end, separator_len)) = find_sse_record_boundary(&self.buffered) {
|
||||||
|
let record = self.buffered[..record_end].to_vec();
|
||||||
|
self.buffered.drain(..record_end + separator_len);
|
||||||
|
match classify_anthropic_sse_record(&record) {
|
||||||
|
AnthropicSseObservation::Pending => {}
|
||||||
|
decision => return decision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
AnthropicSseObservation::SemanticEvent
|
||||||
|
} else {
|
||||||
|
AnthropicSseObservation::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn find_sse_record_boundary(buffer: &[u8]) -> Option<(usize, usize)> {
|
||||||
|
let mut cursor = 0;
|
||||||
|
while cursor < buffer.len() {
|
||||||
|
let (line_end, line_ending_len) = next_sse_line_ending(buffer, cursor)?;
|
||||||
|
let next_line_start = line_end + line_ending_len;
|
||||||
|
let Some((next_line_end, next_line_ending_len)) =
|
||||||
|
next_sse_line_ending(buffer, next_line_start)
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if next_line_end == next_line_start {
|
||||||
|
return Some((
|
||||||
|
line_end,
|
||||||
|
line_ending_len.saturating_add(next_line_ending_len),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
cursor = next_line_start;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_sse_line_ending(buffer: &[u8], start: usize) -> Option<(usize, usize)> {
|
||||||
|
let relative = buffer
|
||||||
|
.get(start..)?
|
||||||
|
.iter()
|
||||||
|
.position(|byte| matches!(byte, b'\r' | b'\n'))?;
|
||||||
|
let index = start + relative;
|
||||||
|
let ending_len = if buffer[index] == b'\r' && buffer.get(index + 1) == Some(&b'\n') {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
Some((index, ending_len))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_anthropic_sse_record(record: &[u8]) -> AnthropicSseObservation {
|
||||||
|
let Ok(record) = std::str::from_utf8(record) else {
|
||||||
|
return AnthropicSseObservation::Pending;
|
||||||
|
};
|
||||||
|
let normalized_record = record.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
let mut event_type = None;
|
||||||
|
let mut data = String::new();
|
||||||
|
for line in normalized_record.lines() {
|
||||||
|
if line.starts_with(':') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("event:") {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
event_type = Some(value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("data:") {
|
||||||
|
if !data.is_empty() {
|
||||||
|
data.push('\n');
|
||||||
|
}
|
||||||
|
data.push_str(value.trim_start());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.trim().is_empty() {
|
||||||
|
return AnthropicSseObservation::Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(body_json) = serde_json::from_str::<Value>(data.trim()) else {
|
||||||
|
return AnthropicSseObservation::Pending;
|
||||||
|
};
|
||||||
|
let payload_type = body_json.get("type").and_then(Value::as_str).map(str::trim);
|
||||||
|
if event_type == Some("error") || payload_type == Some("error") {
|
||||||
|
return AnthropicSseObservation::Error(body_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
let semantic_type = match (event_type, payload_type) {
|
||||||
|
(Some(event_type), Some(payload_type)) if event_type == payload_type => Some(event_type),
|
||||||
|
(None, Some(payload_type)) => Some(payload_type),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if semantic_type.is_some_and(is_anthropic_semantic_event_type) {
|
||||||
|
AnthropicSseObservation::SemanticEvent
|
||||||
|
} else {
|
||||||
|
AnthropicSseObservation::Pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_anthropic_semantic_event_type(event_type: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
event_type,
|
||||||
|
"message_start"
|
||||||
|
| "content_block_start"
|
||||||
|
| "content_block_delta"
|
||||||
|
| "content_block_stop"
|
||||||
|
| "message_delta"
|
||||||
|
| "message_stop"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn anthropic_error_status_code(body_json: &Value) -> u16 {
|
||||||
|
let error_type = body_json
|
||||||
|
.get("error")
|
||||||
|
.and_then(|error| error.get("type"))
|
||||||
|
.or_else(|| body_json.get("type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default();
|
||||||
|
match error_type {
|
||||||
|
"invalid_request_error" => 400,
|
||||||
|
"authentication_error" => 401,
|
||||||
|
"permission_error" => 403,
|
||||||
|
"not_found_error" => 404,
|
||||||
|
"request_too_large" => 413,
|
||||||
|
"rate_limit_error" => 429,
|
||||||
|
"overloaded_error" => 529,
|
||||||
|
_ => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
anthropic_error_status_code, StreamCommitGate, StreamCommitPolicy, StreamCommitState,
|
||||||
|
StreamPrecommitObservation,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn native_anthropic_policy() -> StreamCommitPolicy {
|
||||||
|
StreamCommitPolicy::OnFirstAnthropicSemanticEvent {
|
||||||
|
max_bytes: 16_384,
|
||||||
|
max_wait: Duration::from_millis(750),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_selects_bounded_anthropic_gate_only_for_native_same_format_sse() {
|
||||||
|
let native = StreamCommitPolicy::for_response(
|
||||||
|
true,
|
||||||
|
Some("text/event-stream; charset=utf-8"),
|
||||||
|
"claude:messages",
|
||||||
|
"claude:messages",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert!(native.is_native_anthropic());
|
||||||
|
assert_eq!(
|
||||||
|
native.max_precommit_wait(),
|
||||||
|
Some(Duration::from_millis(750))
|
||||||
|
);
|
||||||
|
assert!(StreamCommitPolicy::for_response(
|
||||||
|
true,
|
||||||
|
Some("text/event-stream"),
|
||||||
|
"openai:chat",
|
||||||
|
"claude:messages",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.commits_on_response_headers());
|
||||||
|
assert!(StreamCommitPolicy::for_response(
|
||||||
|
true,
|
||||||
|
Some("text/event-stream"),
|
||||||
|
"claude:messages",
|
||||||
|
"claude:messages",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.commits_on_response_headers());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_detects_anthropic_error_across_every_chunk_boundary() {
|
||||||
|
let event = b"event: error\r\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}\r\n\r\n";
|
||||||
|
for split in 1..event.len() {
|
||||||
|
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
||||||
|
let first_observation = gate.observe_provider_bytes(&event[..split]);
|
||||||
|
if matches!(
|
||||||
|
first_observation,
|
||||||
|
StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: 529,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
assert_eq!(event[split - 1], b'\r');
|
||||||
|
} else {
|
||||||
|
assert_eq!(first_observation, StreamPrecommitObservation::Pending);
|
||||||
|
assert!(matches!(
|
||||||
|
gate.observe_provider_bytes(&event[split..]),
|
||||||
|
StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: 529,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(gate.state(), StreamCommitState::Terminal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gate_detects_cr_only_and_mixed_line_ending_errors() {
|
||||||
|
for event in [
|
||||||
|
"event: error\rdata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\r\r",
|
||||||
|
"event: error\r\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\r",
|
||||||
|
] {
|
||||||
|
for split in 1..event.len() {
|
||||||
|
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(&event.as_bytes()[..split]),
|
||||||
|
StreamPrecommitObservation::Pending,
|
||||||
|
"gate committed before complete mixed-line event at split {split}",
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
gate.observe_provider_bytes(&event.as_bytes()[split..]),
|
||||||
|
StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: 529,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_and_ping_events_do_not_commit_before_anthropic_error() {
|
||||||
|
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(
|
||||||
|
b"event: future_event\ndata: {\"type\":\"future_event\",\"value\":1}\n\n"
|
||||||
|
),
|
||||||
|
StreamPrecommitObservation::Pending
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(b"event: ping\ndata: {\"type\":\"ping\"}\n\n"),
|
||||||
|
StreamPrecommitObservation::Pending
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
gate.observe_provider_bytes(
|
||||||
|
b"event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}\n\n"
|
||||||
|
),
|
||||||
|
StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: 429,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_semantic_event_commits_before_later_error_in_same_chunk() {
|
||||||
|
let mut gate = StreamCommitGate::new(native_anthropic_policy());
|
||||||
|
let observation = gate.observe_provider_bytes(
|
||||||
|
concat!(
|
||||||
|
"event: message_start\n",
|
||||||
|
"data: {\"type\":\"message_start\",\"message\":{}}\n\n",
|
||||||
|
"event: error\n",
|
||||||
|
"data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n",
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(observation, StreamPrecommitObservation::Commit);
|
||||||
|
assert_eq!(gate.state(), StreamCommitState::Committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_fragment_count_does_not_commit_an_incomplete_anthropic_error() {
|
||||||
|
let policy = StreamCommitPolicy::OnFirstAnthropicSemanticEvent {
|
||||||
|
max_bytes: 1024,
|
||||||
|
max_wait: Duration::from_millis(750),
|
||||||
|
};
|
||||||
|
let mut gate = StreamCommitGate::new(policy);
|
||||||
|
let event = b"event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\"}}\n\n";
|
||||||
|
for byte in &event[..event.len() - 1] {
|
||||||
|
assert_eq!(
|
||||||
|
gate.observe_provider_bytes(std::slice::from_ref(byte)),
|
||||||
|
StreamPrecommitObservation::Pending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
gate.observe_provider_bytes(&event[event.len() - 1..]),
|
||||||
|
StreamPrecommitObservation::UpstreamError {
|
||||||
|
status_code: 529,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_error_status_mapping_matches_messages_api_taxonomy() {
|
||||||
|
for (error_type, status_code) in [
|
||||||
|
("invalid_request_error", 400),
|
||||||
|
("authentication_error", 401),
|
||||||
|
("permission_error", 403),
|
||||||
|
("not_found_error", 404),
|
||||||
|
("request_too_large", 413),
|
||||||
|
("rate_limit_error", 429),
|
||||||
|
("overloaded_error", 529),
|
||||||
|
("api_error", 500),
|
||||||
|
] {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": { "type": error_type, "message": "upstream failure" }
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
anthropic_error_status_code(&body),
|
||||||
|
status_code,
|
||||||
|
"unexpected status for {error_type}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
use aether_ai_serving::AiAttemptRetryScope;
|
||||||
use aether_contracts::{ExecutionError, ExecutionPlan, ExecutionTelemetry};
|
use aether_contracts::{ExecutionError, ExecutionPlan, ExecutionTelemetry};
|
||||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||||
@@ -14,16 +15,17 @@ use tracing::warn;
|
|||||||
use crate::api::response::attach_control_metadata_headers;
|
use crate::api::response::attach_control_metadata_headers;
|
||||||
use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
|
use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
|
use crate::execution_runtime::ai_attempt_retry_scope_from_failure_disposition;
|
||||||
use crate::execution_runtime::submission::{
|
use crate::execution_runtime::submission::{
|
||||||
resolve_core_error_background_report_kind, submit_local_core_error_or_sync_finalize,
|
resolve_core_error_background_report_kind, submit_local_core_error_or_sync_finalize,
|
||||||
};
|
};
|
||||||
use crate::log_ids::short_request_id;
|
use crate::log_ids::short_request_id;
|
||||||
use crate::orchestration::{
|
use crate::orchestration::{
|
||||||
apply_local_execution_effect, resolve_local_failover_analysis_for_attempt,
|
apply_local_execution_effect, classify_failure_disposition,
|
||||||
with_upstream_response_report_context, LocalAdaptiveRateLimitEffect, LocalAttemptFailureEffect,
|
resolve_local_failover_analysis_for_attempt, with_upstream_response_report_context,
|
||||||
LocalExecutionEffect, LocalExecutionEffectContext, LocalFailoverAnalysis,
|
LocalAdaptiveRateLimitEffect, LocalAttemptFailureEffect, LocalExecutionEffect,
|
||||||
LocalFailoverDecision, LocalHealthFailureEffect, LocalOAuthInvalidationEffect,
|
LocalExecutionEffectContext, LocalFailoverAnalysis, LocalFailoverDecision,
|
||||||
LocalPoolErrorEffect,
|
LocalHealthFailureEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
|
||||||
};
|
};
|
||||||
use crate::request_candidate_runtime::record_report_request_candidate_status;
|
use crate::request_candidate_runtime::record_report_request_candidate_status;
|
||||||
use crate::request_diagnostics::attach_current_request_diagnostics_to_report_context;
|
use crate::request_diagnostics::attach_current_request_diagnostics_to_report_context;
|
||||||
@@ -409,9 +411,13 @@ pub(super) async fn handle_prefetch_provider_private_stream_error(
|
|||||||
mut headers: std::collections::BTreeMap<String, String>,
|
mut headers: std::collections::BTreeMap<String, String>,
|
||||||
telemetry: Option<ExecutionTelemetry>,
|
telemetry: Option<ExecutionTelemetry>,
|
||||||
buffered_body: &[u8],
|
buffered_body: &[u8],
|
||||||
|
upstream_status_code: u16,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
body_json: Value,
|
body_json: Value,
|
||||||
|
retry_scope_out: Option<&mut AiAttemptRetryScope>,
|
||||||
|
retry_fallback_out: Option<&mut Option<Response<Body>>>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
|
let upstream_headers = headers.clone();
|
||||||
headers.remove("content-encoding");
|
headers.remove("content-encoding");
|
||||||
headers.remove("content-length");
|
headers.remove("content-length");
|
||||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||||
@@ -441,6 +447,29 @@ pub(super) async fn handle_prefetch_provider_private_stream_error(
|
|||||||
failure_analysis.decision,
|
failure_analysis.decision,
|
||||||
LocalFailoverDecision::RetryNextCandidate
|
LocalFailoverDecision::RetryNextCandidate
|
||||||
) {
|
) {
|
||||||
|
let failure_disposition = classify_failure_disposition(
|
||||||
|
&plan.provider_api_format,
|
||||||
|
failure_analysis.classification,
|
||||||
|
status_code,
|
||||||
|
);
|
||||||
|
if let Some(retry_scope) = retry_scope_out {
|
||||||
|
*retry_scope = ai_attempt_retry_scope_from_failure_disposition(failure_disposition);
|
||||||
|
}
|
||||||
|
if failure_disposition.preserve_upstream_error {
|
||||||
|
if let Some(retry_fallback) = retry_fallback_out {
|
||||||
|
*retry_fallback = Some(attach_control_metadata_headers(
|
||||||
|
crate::api::response::build_client_response_from_parts(
|
||||||
|
upstream_status_code,
|
||||||
|
&upstream_headers,
|
||||||
|
Body::from(buffered_body.to_vec()),
|
||||||
|
trace_id,
|
||||||
|
Some(decision),
|
||||||
|
)?,
|
||||||
|
Some(request_id),
|
||||||
|
candidate_id,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
warn!(
|
warn!(
|
||||||
event_name = "local_stream_candidate_retry_scheduled",
|
event_name = "local_stream_candidate_retry_scheduled",
|
||||||
log_type = "event",
|
log_type = "event",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
mod commit_policy;
|
||||||
mod error;
|
mod error;
|
||||||
mod execution;
|
mod execution;
|
||||||
|
|
||||||
pub(crate) use execution::execute_execution_runtime_stream;
|
pub(crate) use execution::{
|
||||||
|
execute_execution_runtime_stream, execute_execution_runtime_stream_with_retry_scope,
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ use crate::ai_serving::api::{
|
|||||||
};
|
};
|
||||||
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
|
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
|
||||||
use crate::execution_runtime::transport::{
|
use crate::execution_runtime::transport::{
|
||||||
format_hyper_error_chain, format_wreq_upstream_request_error,
|
append_upstream_response_body_chunk, decode_response_body_bytes, format_hyper_error_chain,
|
||||||
stream_first_byte_timeout_message, DirectUpstreamResponse,
|
format_wreq_upstream_request_error, stream_first_byte_timeout_message, DirectUpstreamResponse,
|
||||||
};
|
};
|
||||||
use crate::execution_runtime::DirectUpstreamStreamExecution;
|
use crate::execution_runtime::DirectUpstreamStreamExecution;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
|
||||||
|
const STREAM_USAGE_OBSERVER_MAX_LINE_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
pub(crate) fn build_direct_execution_frame_stream(
|
pub(crate) fn build_direct_execution_frame_stream(
|
||||||
execution: DirectUpstreamStreamExecution,
|
execution: DirectUpstreamStreamExecution,
|
||||||
) -> impl Stream<Item = Result<Bytes, IoError>> + Send + 'static {
|
) -> impl Stream<Item = Result<Bytes, IoError>> + Send + 'static {
|
||||||
@@ -39,6 +41,7 @@ pub(crate) fn build_direct_execution_frame_stream(
|
|||||||
provider_api_format,
|
provider_api_format,
|
||||||
stream_summary_report_context,
|
stream_summary_report_context,
|
||||||
prefetched_body,
|
prefetched_body,
|
||||||
|
stream_precommit_committed: _,
|
||||||
response,
|
response,
|
||||||
started_at,
|
started_at,
|
||||||
stream_first_byte_timeout,
|
stream_first_byte_timeout,
|
||||||
@@ -712,6 +715,23 @@ struct BufferedUpstreamBodyError {
|
|||||||
first_byte_timeout: Option<Duration>,
|
first_byte_timeout: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_buffered_upstream_body_chunk(
|
||||||
|
body_bytes: &mut Vec<u8>,
|
||||||
|
chunk: &[u8],
|
||||||
|
ttfb_ms: Option<u64>,
|
||||||
|
upstream_bytes: &mut u64,
|
||||||
|
) -> Result<(), BufferedUpstreamBodyError> {
|
||||||
|
*upstream_bytes = upstream_bytes.saturating_add(chunk.len() as u64);
|
||||||
|
append_upstream_response_body_chunk(body_bytes, chunk).map_err(|error| {
|
||||||
|
BufferedUpstreamBodyError {
|
||||||
|
message: error.to_string(),
|
||||||
|
ttfb_ms,
|
||||||
|
upstream_bytes: *upstream_bytes,
|
||||||
|
first_byte_timeout: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn response_headers_indicate_sse(headers: &BTreeMap<String, String>) -> bool {
|
fn response_headers_indicate_sse(headers: &BTreeMap<String, String>) -> bool {
|
||||||
headers
|
headers
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
@@ -770,8 +790,12 @@ async fn buffer_non_sse_upstream_body(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
append_buffered_upstream_body_chunk(
|
||||||
body_bytes.extend_from_slice(&chunk);
|
&mut body_bytes,
|
||||||
|
&chunk,
|
||||||
|
ttfb_ms,
|
||||||
|
&mut upstream_bytes,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
return Err(BufferedUpstreamBodyError {
|
return Err(BufferedUpstreamBodyError {
|
||||||
@@ -817,8 +841,12 @@ async fn buffer_non_sse_upstream_body(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
append_buffered_upstream_body_chunk(
|
||||||
body_bytes.extend_from_slice(&chunk);
|
&mut body_bytes,
|
||||||
|
&chunk,
|
||||||
|
ttfb_ms,
|
||||||
|
&mut upstream_bytes,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = format_error_chain(&err);
|
let message = format_error_chain(&err);
|
||||||
@@ -871,8 +899,12 @@ async fn buffer_non_sse_upstream_body(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
append_buffered_upstream_body_chunk(
|
||||||
body_bytes.extend_from_slice(&chunk);
|
&mut body_bytes,
|
||||||
|
&chunk,
|
||||||
|
ttfb_ms,
|
||||||
|
&mut upstream_bytes,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = format_hyper_error_chain(&err);
|
let message = format_hyper_error_chain(&err);
|
||||||
@@ -925,8 +957,12 @@ async fn buffer_non_sse_upstream_body(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
append_buffered_upstream_body_chunk(
|
||||||
body_bytes.extend_from_slice(&chunk);
|
&mut body_bytes,
|
||||||
|
&chunk,
|
||||||
|
ttfb_ms,
|
||||||
|
&mut upstream_bytes,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = format_wreq_upstream_request_error(&err);
|
let message = format_wreq_upstream_request_error(&err);
|
||||||
@@ -974,8 +1010,12 @@ async fn buffer_non_sse_upstream_body(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
append_buffered_upstream_body_chunk(
|
||||||
body_bytes.extend_from_slice(&chunk);
|
&mut body_bytes,
|
||||||
|
&chunk,
|
||||||
|
ttfb_ms,
|
||||||
|
&mut upstream_bytes,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
@@ -1015,13 +1055,13 @@ fn maybe_bridge_non_sse_sync_json_to_stream(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoded_body_bytes = decode_non_sse_response_body_bytes(headers, body_bytes)
|
let decoded_body_bytes = decode_response_body_bytes(headers, body_bytes)
|
||||||
.unwrap_or_else(|| body_bytes.to_vec());
|
.map_err(|error| GatewayError::Internal(error.to_string()))?;
|
||||||
if !response_body_is_json(headers, &decoded_body_bytes) {
|
if !response_body_is_json(headers, decoded_body_bytes.as_ref()) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
|
let body_json: Value = serde_json::from_slice(decoded_body_bytes.as_ref())
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let client_api_format = report_context
|
let client_api_format = report_context
|
||||||
.get("client_api_format")
|
.get("client_api_format")
|
||||||
@@ -1046,33 +1086,6 @@ fn rewrite_headers_for_bridged_sse_response(
|
|||||||
rewritten
|
rewritten
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_non_sse_response_body_bytes(
|
|
||||||
headers: &BTreeMap<String, String>,
|
|
||||||
body_bytes: &[u8],
|
|
||||||
) -> Option<Vec<u8>> {
|
|
||||||
let encoding = headers
|
|
||||||
.get("content-encoding")
|
|
||||||
.map(String::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(|value| value.to_ascii_lowercase());
|
|
||||||
match encoding.as_deref() {
|
|
||||||
Some("gzip") => {
|
|
||||||
let mut decoder = flate2::read::GzDecoder::new(body_bytes);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
std::io::Read::read_to_end(&mut decoder, &mut out).ok()?;
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
Some("deflate") => {
|
|
||||||
let mut decoder = flate2::read::DeflateDecoder::new(body_bytes);
|
|
||||||
let mut out = Vec::new();
|
|
||||||
std::io::Read::read_to_end(&mut decoder, &mut out).ok()?;
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
|
fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
|
||||||
if headers
|
if headers
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
@@ -1159,16 +1172,39 @@ fn observe_normalized_bytes(
|
|||||||
observer_buffered: &mut Vec<u8>,
|
observer_buffered: &mut Vec<u8>,
|
||||||
normalized: &[u8],
|
normalized: &[u8],
|
||||||
) {
|
) {
|
||||||
if normalized.is_empty() {
|
if normalized.is_empty()
|
||||||
|
|| observer
|
||||||
|
.latest_summary()
|
||||||
|
.and_then(|summary| summary.parser_error.as_deref())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
observer_buffered.extend_from_slice(normalized);
|
|
||||||
while let Some(line_end) = observer_buffered.iter().position(|byte| *byte == b'\n') {
|
let mut remaining = normalized;
|
||||||
let line = observer_buffered.drain(..=line_end).collect::<Vec<_>>();
|
while !remaining.is_empty() {
|
||||||
if let Err(err) = observer.push_line(report_context, line) {
|
let line_part_len = remaining
|
||||||
observer.disable_with_error(err.to_string());
|
.iter()
|
||||||
|
.position(|byte| *byte == b'\n')
|
||||||
|
.map_or(remaining.len(), |index| index + 1);
|
||||||
|
if observer_buffered.len().saturating_add(line_part_len)
|
||||||
|
> STREAM_USAGE_OBSERVER_MAX_LINE_BYTES
|
||||||
|
{
|
||||||
|
observer.disable_with_error(format!(
|
||||||
|
"stream usage event exceeded {STREAM_USAGE_OBSERVER_MAX_LINE_BYTES} bytes"
|
||||||
|
));
|
||||||
observer_buffered.clear();
|
observer_buffered.clear();
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
observer_buffered.extend_from_slice(&remaining[..line_part_len]);
|
||||||
|
remaining = &remaining[line_part_len..];
|
||||||
|
if observer_buffered.last() == Some(&b'\n') {
|
||||||
|
let line = std::mem::take(observer_buffered);
|
||||||
|
if let Err(err) = observer.push_line(report_context, line) {
|
||||||
|
observer.disable_with_error(err.to_string());
|
||||||
|
observer_buffered.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1193,9 +1229,11 @@ mod tests {
|
|||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_direct_execution_frame_stream, should_buffer_non_stream_response,
|
build_direct_execution_frame_stream, observe_normalized_bytes,
|
||||||
should_treat_upstream_response_as_stream,
|
should_buffer_non_stream_response, should_treat_upstream_response_as_stream,
|
||||||
|
STREAM_USAGE_OBSERVER_MAX_LINE_BYTES,
|
||||||
};
|
};
|
||||||
|
use crate::ai_serving::api::StreamingStandardTerminalObserver;
|
||||||
use crate::execution_runtime::transport::{
|
use crate::execution_runtime::transport::{
|
||||||
execute_stream_plan_via_local_tunnel, DirectSyncExecutionRuntime, DirectUpstreamResponse,
|
execute_stream_plan_via_local_tunnel, DirectSyncExecutionRuntime, DirectUpstreamResponse,
|
||||||
};
|
};
|
||||||
@@ -1260,6 +1298,27 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_usage_line_disables_observation_without_retaining_the_line() {
|
||||||
|
let mut observer = StreamingStandardTerminalObserver::default();
|
||||||
|
let report_context = serde_json::json!({
|
||||||
|
"provider_api_format": "claude:messages",
|
||||||
|
"client_api_format": "claude:messages",
|
||||||
|
});
|
||||||
|
let mut buffered = Vec::new();
|
||||||
|
let oversized = vec![b'x'; STREAM_USAGE_OBSERVER_MAX_LINE_BYTES + 1];
|
||||||
|
|
||||||
|
observe_normalized_bytes(&mut observer, &report_context, &mut buffered, &oversized);
|
||||||
|
|
||||||
|
assert!(buffered.is_empty());
|
||||||
|
assert!(observer
|
||||||
|
.latest_summary()
|
||||||
|
.and_then(|summary| summary.parser_error.as_deref())
|
||||||
|
.is_some_and(|error| error.contains("stream usage event exceeded")));
|
||||||
|
observe_normalized_bytes(&mut observer, &report_context, &mut buffered, b"ignored");
|
||||||
|
assert!(buffered.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn direct_execution_frame_stream_reports_ttfb_after_first_upstream_chunk() {
|
async fn direct_execution_frame_stream_reports_ttfb_after_first_upstream_chunk() {
|
||||||
let listener = crate::test_support::bind_loopback_listener()
|
let listener = crate::test_support::bind_loopback_listener()
|
||||||
|
|||||||
@@ -496,6 +496,15 @@ fn classify_local_sync_error_kind(
|
|||||||
{
|
{
|
||||||
return LocalCoreSyncErrorKind::RateLimit;
|
return LocalCoreSyncErrorKind::RateLimit;
|
||||||
}
|
}
|
||||||
|
if status_code == 413
|
||||||
|
|| fingerprint.contains("request_too_large")
|
||||||
|
|| fingerprint.contains("request too large")
|
||||||
|
|| fingerprint.contains("payload_too_large")
|
||||||
|
|| fingerprint.contains("payload too large")
|
||||||
|
|| fingerprint.contains("request entity too large")
|
||||||
|
{
|
||||||
|
return LocalCoreSyncErrorKind::RequestTooLarge;
|
||||||
|
}
|
||||||
if fingerprint.contains("contextlength")
|
if fingerprint.contains("contextlength")
|
||||||
|| fingerprint.contains("contentlengthexceeded")
|
|| fingerprint.contains("contentlengthexceeded")
|
||||||
|| fingerprint.contains("context window")
|
|| fingerprint.contains("context window")
|
||||||
@@ -534,6 +543,7 @@ fn default_status_code_for_local_sync_error_kind(kind: LocalCoreSyncErrorKind) -
|
|||||||
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => {
|
||||||
400
|
400
|
||||||
}
|
}
|
||||||
|
LocalCoreSyncErrorKind::RequestTooLarge => 413,
|
||||||
LocalCoreSyncErrorKind::Authentication => 401,
|
LocalCoreSyncErrorKind::Authentication => 401,
|
||||||
LocalCoreSyncErrorKind::PermissionDenied => 403,
|
LocalCoreSyncErrorKind::PermissionDenied => 403,
|
||||||
LocalCoreSyncErrorKind::NotFound => 404,
|
LocalCoreSyncErrorKind::NotFound => 404,
|
||||||
@@ -792,6 +802,76 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_core_error_maps_request_too_large_without_changing_openai_shape() {
|
||||||
|
let claude_payload = core_finalize_payload(
|
||||||
|
"claude_chat_sync_finalize",
|
||||||
|
"claude:messages",
|
||||||
|
"openai:chat",
|
||||||
|
413,
|
||||||
|
json!({
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": "request body is too large"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let claude_response = maybe_build_local_core_error_response(
|
||||||
|
"trace-sync-claude-too-large",
|
||||||
|
&test_decision(),
|
||||||
|
&claude_payload,
|
||||||
|
)
|
||||||
|
.expect("response build should not error")
|
||||||
|
.expect("response should exist");
|
||||||
|
assert_eq!(
|
||||||
|
claude_response.status(),
|
||||||
|
http::StatusCode::PAYLOAD_TOO_LARGE
|
||||||
|
);
|
||||||
|
let claude_body: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(claude_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("body should decode");
|
||||||
|
assert_eq!(claude_body["type"], "error");
|
||||||
|
assert_eq!(claude_body["error"]["type"], "request_too_large");
|
||||||
|
|
||||||
|
let openai_payload = core_finalize_payload(
|
||||||
|
"openai_chat_sync_finalize",
|
||||||
|
"openai:chat",
|
||||||
|
"claude:messages",
|
||||||
|
200,
|
||||||
|
json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "request_too_large",
|
||||||
|
"message": "request body is too large"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let openai_response = maybe_build_local_core_error_response(
|
||||||
|
"trace-sync-openai-too-large",
|
||||||
|
&test_decision(),
|
||||||
|
&openai_payload,
|
||||||
|
)
|
||||||
|
.expect("response build should not error")
|
||||||
|
.expect("response should exist");
|
||||||
|
assert_eq!(
|
||||||
|
openai_response.status(),
|
||||||
|
http::StatusCode::PAYLOAD_TOO_LARGE
|
||||||
|
);
|
||||||
|
let openai_body: serde_json::Value = serde_json::from_slice(
|
||||||
|
&to_bytes(openai_response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body should read"),
|
||||||
|
)
|
||||||
|
.expect("body should decode");
|
||||||
|
assert_eq!(
|
||||||
|
openai_body["error"]["type"], "context_length_exceeded",
|
||||||
|
"OpenAI compatibility shape should remain unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_core_sync_finalize_rejects_gemini_http_200_without_visible_output() {
|
async fn local_core_sync_finalize_rejects_gemini_http_200_without_visible_output() {
|
||||||
let mut payload = core_finalize_payload(
|
let mut payload = core_finalize_payload(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::io::Error as IoError;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use aether_ai_serving::UPSTREAM_IS_STREAM_KEY;
|
use aether_ai_serving::{AiAttemptExecutionOutcome, AiAttemptRetryScope, UPSTREAM_IS_STREAM_KEY};
|
||||||
use aether_contracts::{
|
use aether_contracts::{
|
||||||
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan, ExecutionResult,
|
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan, ExecutionResult,
|
||||||
ExecutionTelemetry,
|
ExecutionTelemetry,
|
||||||
@@ -55,17 +55,18 @@ use crate::execution_runtime::submission::{
|
|||||||
resolve_local_sync_error_status_code, submit_local_core_error_or_sync_finalize,
|
resolve_local_sync_error_status_code, submit_local_core_error_or_sync_finalize,
|
||||||
};
|
};
|
||||||
use crate::execution_runtime::transport::{
|
use crate::execution_runtime::transport::{
|
||||||
build_execution_response_body, build_request_body, collect_response_headers,
|
append_upstream_response_body_chunk, build_execution_response_body, build_request_body,
|
||||||
decode_response_body_bytes, format_hyper_error_chain, format_upstream_request_error,
|
collect_response_headers, decode_response_body_bytes, execution_response_body_mode,
|
||||||
format_wreq_upstream_request_error, response_body_is_json, send_request, DirectHttpResponse,
|
format_hyper_error_chain, format_upstream_request_error, format_wreq_upstream_request_error,
|
||||||
DirectSyncExecutionRuntime, ExecutionRuntimeTransportError,
|
response_body_is_json, send_request, DirectHttpResponse, DirectSyncExecutionRuntime,
|
||||||
|
ExecutionRuntimeTransportError,
|
||||||
};
|
};
|
||||||
use crate::execution_runtime::windsurf::maybe_execute_windsurf_sync;
|
use crate::execution_runtime::windsurf::maybe_execute_windsurf_sync;
|
||||||
use crate::execution_runtime::{
|
use crate::execution_runtime::{
|
||||||
analyze_local_candidate_failover_sync, apply_endpoint_response_header_rules,
|
ai_attempt_retry_scope_from_failure_disposition, analyze_local_candidate_failover_sync,
|
||||||
attach_provider_response_headers_to_report_context, local_failover_response_text,
|
apply_endpoint_response_header_rules, attach_provider_response_headers_to_report_context,
|
||||||
resolve_core_sync_error_finalize_report_kind, should_fallback_to_control_sync,
|
local_failover_response_text, resolve_core_sync_error_finalize_report_kind,
|
||||||
should_finalize_sync_response, LocalFailoverDecision,
|
should_fallback_to_control_sync, should_finalize_sync_response, LocalFailoverDecision,
|
||||||
};
|
};
|
||||||
use crate::log_ids::short_request_id;
|
use crate::log_ids::short_request_id;
|
||||||
use crate::orchestration::{
|
use crate::orchestration::{
|
||||||
@@ -113,6 +114,29 @@ struct SyncExecutionFailure {
|
|||||||
message: String,
|
message: String,
|
||||||
status_code: Option<u16>,
|
status_code: Option<u16>,
|
||||||
latency_ms: Option<u64>,
|
latency_ms: Option<u64>,
|
||||||
|
fallback_kind: Option<SyncExecutionFailureFallbackKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SyncExecutionFailureFallbackKind {
|
||||||
|
UpstreamResponseTooLarge,
|
||||||
|
UpstreamResponseDecode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncExecutionFailureFallbackKind {
|
||||||
|
fn error_type(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::UpstreamResponseTooLarge => "upstream_response_too_large",
|
||||||
|
Self::UpstreamResponseDecode => "upstream_response_decode_failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_message(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::UpstreamResponseTooLarge => "Upstream response too large",
|
||||||
|
Self::UpstreamResponseDecode => "Failed to decode upstream response",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SyncAttemptTerminalGuard {
|
struct SyncAttemptTerminalGuard {
|
||||||
@@ -269,11 +293,23 @@ async fn record_sync_attempt_forced_terminal_state(
|
|||||||
|
|
||||||
impl SyncExecutionFailure {
|
impl SyncExecutionFailure {
|
||||||
fn from_transport(err: ExecutionRuntimeTransportError) -> Self {
|
fn from_transport(err: ExecutionRuntimeTransportError) -> Self {
|
||||||
|
let fallback_kind = match &err {
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseTooLarge { .. } => {
|
||||||
|
Some(SyncExecutionFailureFallbackKind::UpstreamResponseTooLarge)
|
||||||
|
}
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseDecode { .. } => {
|
||||||
|
Some(SyncExecutionFailureFallbackKind::UpstreamResponseDecode)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
error_type: "execution_runtime_unavailable",
|
error_type: fallback_kind
|
||||||
|
.map(SyncExecutionFailureFallbackKind::error_type)
|
||||||
|
.unwrap_or("execution_runtime_unavailable"),
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
status_code: None,
|
status_code: fallback_kind.map(|_| StatusCode::BAD_GATEWAY.as_u16()),
|
||||||
latency_ms: None,
|
latency_ms: None,
|
||||||
|
fallback_kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,10 +321,94 @@ impl SyncExecutionFailure {
|
|||||||
),
|
),
|
||||||
status_code: Some(StatusCode::GATEWAY_TIMEOUT.as_u16()),
|
status_code: Some(StatusCode::GATEWAY_TIMEOUT.as_u16()),
|
||||||
latency_ms: Some(elapsed_ms),
|
latency_ms: Some(elapsed_ms),
|
||||||
|
fallback_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_sync_execution_failure_fallback_body(
|
||||||
|
client_api_format: &str,
|
||||||
|
kind: SyncExecutionFailureFallbackKind,
|
||||||
|
) -> Value {
|
||||||
|
let message = kind.client_message();
|
||||||
|
let error_type = kind.error_type();
|
||||||
|
match crate::ai_serving::normalize_api_format_alias(client_api_format).as_str() {
|
||||||
|
"claude:messages" => json!({
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "upstream_error",
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"gemini:generate_content" => json!({
|
||||||
|
"error": {
|
||||||
|
"code": StatusCode::BAD_GATEWAY.as_u16(),
|
||||||
|
"message": message,
|
||||||
|
"status": "BAD_GATEWAY",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
_ => json!({
|
||||||
|
"error": {
|
||||||
|
"type": "upstream_error",
|
||||||
|
"message": message,
|
||||||
|
"code": error_type,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_sync_execution_failure_fallback_response(
|
||||||
|
failure: &SyncExecutionFailure,
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
trace_id: &str,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
|
let Some(kind) = failure.fallback_kind else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let body_json = build_sync_execution_failure_fallback_body(&plan.client_api_format, kind);
|
||||||
|
let body_bytes = serde_json::to_vec(&body_json)
|
||||||
|
.map_err(|error| GatewayError::Internal(error.to_string()))?;
|
||||||
|
let headers = BTreeMap::from([
|
||||||
|
("content-type".to_string(), "application/json".to_string()),
|
||||||
|
("content-length".to_string(), body_bytes.len().to_string()),
|
||||||
|
]);
|
||||||
|
let response = build_client_response_from_parts(
|
||||||
|
StatusCode::BAD_GATEWAY.as_u16(),
|
||||||
|
&headers,
|
||||||
|
Body::from(body_bytes),
|
||||||
|
trace_id,
|
||||||
|
Some(decision),
|
||||||
|
)?;
|
||||||
|
attach_control_metadata_headers(
|
||||||
|
response,
|
||||||
|
Some(plan.request_id.as_str()),
|
||||||
|
plan.candidate_id.as_deref(),
|
||||||
|
)
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_store_sync_execution_failure_fallback(
|
||||||
|
failure: &SyncExecutionFailure,
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
trace_id: &str,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
retry_scope_out: &mut Option<&mut AiAttemptRetryScope>,
|
||||||
|
retry_fallback_out: &mut Option<&mut Option<Response<Body>>>,
|
||||||
|
) -> Result<(), GatewayError> {
|
||||||
|
if failure.fallback_kind.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
|
||||||
|
*retry_scope = AiAttemptRetryScope::Candidate;
|
||||||
|
}
|
||||||
|
if let Some(retry_fallback) = retry_fallback_out.as_deref_mut() {
|
||||||
|
*retry_fallback =
|
||||||
|
build_sync_execution_failure_fallback_response(failure, plan, trace_id, decision)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
struct ImplicitSyncFinalizeOutcome {
|
struct ImplicitSyncFinalizeOutcome {
|
||||||
payload: GatewaySyncReportRequest,
|
payload: GatewaySyncReportRequest,
|
||||||
outcome: LocalCoreSyncFinalizeOutcome,
|
outcome: LocalCoreSyncFinalizeOutcome,
|
||||||
@@ -1297,11 +1417,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||||
|
.map_err(SyncExecutionFailure::from_transport)?;
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
progress
|
progress
|
||||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||||
.await;
|
.await;
|
||||||
body_bytes.extend_from_slice(&chunk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DirectHttpResponse::HyperH2c(response) => {
|
DirectHttpResponse::HyperH2c(response) => {
|
||||||
@@ -1314,11 +1435,12 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||||
|
.map_err(SyncExecutionFailure::from_transport)?;
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
progress
|
progress
|
||||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||||
.await;
|
.await;
|
||||||
body_bytes.extend_from_slice(&chunk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DirectHttpResponse::BrowserWreq(response) => {
|
DirectHttpResponse::BrowserWreq(response) => {
|
||||||
@@ -1331,24 +1453,30 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)
|
||||||
|
.map_err(SyncExecutionFailure::from_transport)?;
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
progress
|
progress
|
||||||
.observe_chunk(&chunk, status_code, elapsed_ms)
|
.observe_chunk(&chunk, status_code, elapsed_ms)
|
||||||
.await;
|
.await;
|
||||||
body_bytes.extend_from_slice(&chunk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoded_body_bytes =
|
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)
|
||||||
decode_response_body_bytes(&headers, &body_bytes).unwrap_or_else(|| body_bytes.clone());
|
.map_err(SyncExecutionFailure::from_transport)?;
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
let upstream_bytes = body_bytes.len() as u64;
|
let upstream_bytes = body_bytes.len() as u64;
|
||||||
progress.finish(status_code, elapsed_ms).await;
|
progress.finish(status_code, elapsed_ms).await;
|
||||||
|
|
||||||
let body =
|
let body = build_execution_response_body(
|
||||||
build_execution_response_body(&headers, &body_bytes, &decoded_body_bytes, plan.stream)
|
&headers,
|
||||||
.map_err(SyncExecutionFailure::from_transport)?;
|
&body_bytes,
|
||||||
|
decoded_body_bytes.as_ref(),
|
||||||
|
plan.stream,
|
||||||
|
execution_response_body_mode(plan),
|
||||||
|
)
|
||||||
|
.map_err(SyncExecutionFailure::from_transport)?;
|
||||||
|
|
||||||
Ok(ExecutionResult {
|
Ok(ExecutionResult {
|
||||||
request_id: plan.request_id.clone(),
|
request_id: plan.request_id.clone(),
|
||||||
@@ -1451,6 +1579,8 @@ fn build_openai_image_sync_json_heartbeat_response(
|
|||||||
report_context,
|
report_context,
|
||||||
false,
|
false,
|
||||||
Some(progress_snapshot),
|
Some(progress_snapshot),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
@@ -1631,10 +1761,49 @@ pub(crate) async fn execute_execution_runtime_sync(
|
|||||||
report_context,
|
report_context,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) async fn execute_execution_runtime_sync_with_retry_scope(
|
||||||
|
state: &AppState,
|
||||||
|
request_path: &str,
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
trace_id: &str,
|
||||||
|
decision: &GatewayControlDecision,
|
||||||
|
plan_kind: &str,
|
||||||
|
report_kind: Option<String>,
|
||||||
|
report_context: Option<serde_json::Value>,
|
||||||
|
) -> Result<AiAttemptExecutionOutcome<Response<Body>>, GatewayError> {
|
||||||
|
let mut retry_scope = AiAttemptRetryScope::Candidate;
|
||||||
|
let mut fallback_response = None;
|
||||||
|
let response = execute_execution_runtime_sync_impl(
|
||||||
|
state,
|
||||||
|
request_path,
|
||||||
|
plan,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
plan_kind,
|
||||||
|
report_kind,
|
||||||
|
report_context,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some(&mut retry_scope),
|
||||||
|
Some(&mut fallback_response),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(match response {
|
||||||
|
Some(response) => AiAttemptExecutionOutcome::Responded(response),
|
||||||
|
None => AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope: retry_scope,
|
||||||
|
fallback_response,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)] // internal function, grouping would add unnecessary indirection
|
#[allow(clippy::too_many_arguments)] // internal function, grouping would add unnecessary indirection
|
||||||
async fn execute_execution_runtime_sync_impl(
|
async fn execute_execution_runtime_sync_impl(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -1647,6 +1816,8 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
mut report_context: Option<serde_json::Value>,
|
mut report_context: Option<serde_json::Value>,
|
||||||
allow_json_heartbeat: bool,
|
allow_json_heartbeat: bool,
|
||||||
progress_snapshot: Option<Arc<Mutex<OpenAiImageSyncProgressSnapshot>>>,
|
progress_snapshot: Option<Arc<Mutex<OpenAiImageSyncProgressSnapshot>>>,
|
||||||
|
mut retry_scope_out: Option<&mut AiAttemptRetryScope>,
|
||||||
|
mut retry_fallback_out: Option<&mut Option<Response<Body>>>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
if allow_json_heartbeat
|
if allow_json_heartbeat
|
||||||
&& should_enable_openai_image_sync_json_heartbeat(plan_kind, &plan, report_context.as_ref())
|
&& should_enable_openai_image_sync_json_heartbeat(plan_kind, &plan, report_context.as_ref())
|
||||||
@@ -1751,6 +1922,14 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
{
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
maybe_store_sync_execution_failure_fallback(
|
||||||
|
&err,
|
||||||
|
&plan,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
&mut retry_scope_out,
|
||||||
|
&mut retry_fallback_out,
|
||||||
|
)?;
|
||||||
warn!(
|
warn!(
|
||||||
event_name = "sync_execution_runtime_unavailable",
|
event_name = "sync_execution_runtime_unavailable",
|
||||||
log_type = "ops",
|
log_type = "ops",
|
||||||
@@ -1932,6 +2111,14 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
{
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
maybe_store_sync_execution_failure_fallback(
|
||||||
|
&err,
|
||||||
|
&plan,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
&mut retry_scope_out,
|
||||||
|
&mut retry_fallback_out,
|
||||||
|
)?;
|
||||||
warn!(
|
warn!(
|
||||||
event_name = "sync_execution_runtime_unavailable",
|
event_name = "sync_execution_runtime_unavailable",
|
||||||
log_type = "ops",
|
log_type = "ops",
|
||||||
@@ -2256,6 +2443,38 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
local_failover_analysis.decision,
|
local_failover_analysis.decision,
|
||||||
LocalFailoverDecision::RetryNextCandidate
|
LocalFailoverDecision::RetryNextCandidate
|
||||||
) {
|
) {
|
||||||
|
let failure_disposition = crate::orchestration::classify_failure_disposition(
|
||||||
|
&plan.provider_api_format,
|
||||||
|
local_failover_analysis.classification,
|
||||||
|
result.status_code,
|
||||||
|
);
|
||||||
|
if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
|
||||||
|
*retry_scope =
|
||||||
|
ai_attempt_retry_scope_from_failure_disposition(failure_disposition);
|
||||||
|
}
|
||||||
|
if failure_disposition.preserve_upstream_error {
|
||||||
|
if let Some(retry_fallback) = retry_fallback_out.as_deref_mut() {
|
||||||
|
let mut fallback_headers = headers.clone();
|
||||||
|
apply_endpoint_response_header_rules(
|
||||||
|
state,
|
||||||
|
&plan,
|
||||||
|
&mut fallback_headers,
|
||||||
|
body_json.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
*retry_fallback = Some(attach_control_metadata_headers(
|
||||||
|
build_client_response_from_parts(
|
||||||
|
result.status_code,
|
||||||
|
&fallback_headers,
|
||||||
|
Body::from(body_bytes.clone()),
|
||||||
|
trace_id,
|
||||||
|
Some(decision),
|
||||||
|
)?,
|
||||||
|
Some(plan.request_id.as_str()),
|
||||||
|
plan.candidate_id.as_deref(),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
let terminal_unix_secs = current_request_candidate_unix_ms();
|
let terminal_unix_secs = current_request_candidate_unix_ms();
|
||||||
let error_trace_report_context = with_sync_error_trace_context(
|
let error_trace_report_context = with_sync_error_trace_context(
|
||||||
report_context.as_ref(),
|
report_context.as_ref(),
|
||||||
@@ -2909,6 +3128,60 @@ mod tests {
|
|||||||
.with_execution_runtime_candidate(true)
|
.with_execution_runtime_candidate(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_upstream_response_builds_claude_502_retry_fallback() {
|
||||||
|
let mut plan = test_openai_image_plan(false);
|
||||||
|
plan.client_api_format = "claude:messages".to_string();
|
||||||
|
plan.provider_api_format = "claude:messages".to_string();
|
||||||
|
let decision = GatewayControlDecision::synthetic(
|
||||||
|
"/v1/messages",
|
||||||
|
Some("ai_public".to_string()),
|
||||||
|
Some("claude".to_string()),
|
||||||
|
Some("messages".to_string()),
|
||||||
|
Some("claude:messages".to_string()),
|
||||||
|
)
|
||||||
|
.with_execution_runtime_candidate(true);
|
||||||
|
let failure = SyncExecutionFailure::from_transport(
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||||
|
phase: crate::execution_runtime::transport::UpstreamResponseBodyPhase::Wire,
|
||||||
|
limit_bytes: 8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(failure.status_code, Some(StatusCode::BAD_GATEWAY.as_u16()));
|
||||||
|
assert_eq!(
|
||||||
|
failure.fallback_kind,
|
||||||
|
Some(SyncExecutionFailureFallbackKind::UpstreamResponseTooLarge)
|
||||||
|
);
|
||||||
|
let mut retry_scope = AiAttemptRetryScope::Provider;
|
||||||
|
let mut retry_fallback = None;
|
||||||
|
{
|
||||||
|
let mut retry_scope_out = Some(&mut retry_scope);
|
||||||
|
let mut retry_fallback_out = Some(&mut retry_fallback);
|
||||||
|
maybe_store_sync_execution_failure_fallback(
|
||||||
|
&failure,
|
||||||
|
&plan,
|
||||||
|
"trace-too-large",
|
||||||
|
&decision,
|
||||||
|
&mut retry_scope_out,
|
||||||
|
&mut retry_fallback_out,
|
||||||
|
)
|
||||||
|
.expect("fallback response should build");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(retry_scope, AiAttemptRetryScope::Candidate);
|
||||||
|
let response = retry_fallback.expect("oversized response should provide a fallback");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
|
let body = to_bytes(response.into_body(), 1024)
|
||||||
|
.await
|
||||||
|
.expect("fallback body should read");
|
||||||
|
let body: Value = serde_json::from_slice(&body).expect("fallback body should be json");
|
||||||
|
assert_eq!(body["type"], "error");
|
||||||
|
assert_eq!(body["error"]["type"], "upstream_error");
|
||||||
|
assert_eq!(body["error"]["message"], "Upstream response too large");
|
||||||
|
}
|
||||||
|
|
||||||
fn test_kiro_sync_plan() -> ExecutionPlan {
|
fn test_kiro_sync_plan() -> ExecutionPlan {
|
||||||
ExecutionPlan {
|
ExecutionPlan {
|
||||||
request_id: "req-kiro-sync-cache-1".to_string(),
|
request_id: "req-kiro-sync-cache-1".to_string(),
|
||||||
|
|||||||
@@ -14,8 +14,19 @@ pub(super) fn decode_execution_result_body(
|
|||||||
let Some(body) = body else {
|
let Some(body) = body else {
|
||||||
return Ok((Vec::new(), None, None));
|
return Ok((Vec::new(), None, None));
|
||||||
};
|
};
|
||||||
|
let ResponseBody {
|
||||||
|
json_body,
|
||||||
|
body_bytes_b64,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if let Some(json_body) = body.json_body {
|
if let Some(body_bytes_b64) = body_bytes_b64 {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&body_bytes_b64)
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
|
return Ok((bytes, json_body, Some(body_bytes_b64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(json_body) = json_body {
|
||||||
remove_header_case_insensitive(headers, "content-encoding");
|
remove_header_case_insensitive(headers, "content-encoding");
|
||||||
remove_header_case_insensitive(headers, "content-length");
|
remove_header_case_insensitive(headers, "content-length");
|
||||||
headers
|
headers
|
||||||
@@ -27,13 +38,6 @@ pub(super) fn decode_execution_result_body(
|
|||||||
return Ok((bytes, Some(json_body), None));
|
return Ok((bytes, Some(json_body), None));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(body_bytes_b64) = body.body_bytes_b64 {
|
|
||||||
let bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(&body_bytes_b64)
|
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
|
||||||
return Ok((bytes, None, Some(body_bytes_b64)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((Vec::new(), None, None))
|
Ok((Vec::new(), None, None))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ mod tests {
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use aether_contracts::ResponseBody;
|
use aether_contracts::ResponseBody;
|
||||||
|
use base64::Engine as _;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::decode_execution_result_body;
|
use super::decode_execution_result_body;
|
||||||
@@ -81,4 +86,36 @@ mod tests {
|
|||||||
Some(body_bytes.len().to_string())
|
Some(body_bytes.len().to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dual_body_prefers_wire_bytes_and_retains_parsed_json() {
|
||||||
|
let raw = br#"{ "unknown": true, "ok": true }"#;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(raw);
|
||||||
|
let raw_len = raw.len().to_string();
|
||||||
|
let mut headers = BTreeMap::from([
|
||||||
|
("content-encoding".to_string(), "gzip".to_string()),
|
||||||
|
("content-length".to_string(), raw_len.clone()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (body_bytes, body_json, body_base64) = decode_execution_result_body(
|
||||||
|
Some(ResponseBody {
|
||||||
|
json_body: Some(json!({"unknown": true, "ok": true})),
|
||||||
|
body_bytes_b64: Some(encoded.clone()),
|
||||||
|
}),
|
||||||
|
&mut headers,
|
||||||
|
)
|
||||||
|
.expect("body should decode");
|
||||||
|
|
||||||
|
assert_eq!(body_bytes, raw);
|
||||||
|
assert_eq!(body_json, Some(json!({"unknown": true, "ok": true})));
|
||||||
|
assert_eq!(body_base64.as_deref(), Some(encoded.as_str()));
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("content-encoding").map(String::as_str),
|
||||||
|
Some("gzip")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("content-length").map(String::as_str),
|
||||||
|
Some(raw_len.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod execution;
|
|||||||
pub(crate) use execution::{
|
pub(crate) use execution::{
|
||||||
build_openai_image_sync_json_whitespace_heartbeat_stream,
|
build_openai_image_sync_json_whitespace_heartbeat_stream,
|
||||||
build_sync_json_whitespace_heartbeat_stream, execute_execution_runtime_sync,
|
build_sync_json_whitespace_heartbeat_stream, execute_execution_runtime_sync,
|
||||||
|
execute_execution_runtime_sync_with_retry_scope,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ fn build_best_effort_local_core_error_body_converts_sync_errors_across_standard_
|
|||||||
"type": "error",
|
"type": "error",
|
||||||
"error": {
|
"error": {
|
||||||
"message": "backend busy",
|
"message": "backend busy",
|
||||||
"type": "api_error",
|
"type": "overloaded_error",
|
||||||
"code": "UNAVAILABLE"
|
"code": "UNAVAILABLE"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
use std::error::Error as _;
|
use std::error::Error as _;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -8,11 +9,12 @@ use std::sync::{Arc, LazyLock, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use aether_contracts::{
|
use aether_contracts::{
|
||||||
ExecutionPlan, ExecutionResult, ExecutionTelemetry, ProxySnapshot, ResolvedTransportProfile,
|
ExecutionPlan, ExecutionResponseBodyMode, ExecutionResult, ExecutionTelemetry, ProxySnapshot,
|
||||||
ResponseBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
|
ResolvedTransportProfile, ResponseBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
|
||||||
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
||||||
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_BACKEND_REQWEST_RUSTLS,
|
EXECUTION_RESPONSE_BODY_MODE_HEADER, TRANSPORT_BACKEND_BROWSER_WREQ,
|
||||||
TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
|
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE,
|
||||||
|
TRANSPORT_HTTP_MODE_HTTP1_ONLY,
|
||||||
};
|
};
|
||||||
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
|
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
|
||||||
use aether_http::{apply_http_client_config, HttpClientConfig};
|
use aether_http::{apply_http_client_config, HttpClientConfig};
|
||||||
@@ -35,6 +37,7 @@ use reqwest::redirect::Policy;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use sha2::Digest as _;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::OnceCell as TokioOnceCell;
|
use tokio::sync::OnceCell as TokioOnceCell;
|
||||||
@@ -107,6 +110,7 @@ type DirectHyperH2cSenderCacheCell = TokioOnceCell<Arc<DirectHyperH2cSenderCache
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
struct DirectReqwestClientCacheKey {
|
struct DirectReqwestClientCacheKey {
|
||||||
upstream_origin: Option<String>,
|
upstream_origin: Option<String>,
|
||||||
|
pool_partition: Option<String>,
|
||||||
connect_timeout_ms: Option<u64>,
|
connect_timeout_ms: Option<u64>,
|
||||||
proxy_url: Option<String>,
|
proxy_url: Option<String>,
|
||||||
follow_redirects: bool,
|
follow_redirects: bool,
|
||||||
@@ -444,8 +448,11 @@ pub(crate) fn format_upstream_request_error(err: &reqwest::Error) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = err.url() {
|
if let Some(url) = err.url() {
|
||||||
|
let (sanitized_detail, sanitized_url) =
|
||||||
|
sanitize_upstream_request_error_detail(&detail, url.as_str());
|
||||||
|
detail = sanitized_detail;
|
||||||
detail.push_str(" [url=");
|
detail.push_str(" [url=");
|
||||||
detail.push_str(url.as_str());
|
detail.push_str(&sanitized_url);
|
||||||
detail.push(']');
|
detail.push(']');
|
||||||
}
|
}
|
||||||
if !kinds.is_empty() {
|
if !kinds.is_empty() {
|
||||||
@@ -457,6 +464,25 @@ pub(crate) fn format_upstream_request_error(err: &reqwest::Error) -> String {
|
|||||||
detail
|
detail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_upstream_request_error_detail(detail: &str, upstream_url: &str) -> (String, String) {
|
||||||
|
let sanitized_url = sanitize_upstream_url_text(upstream_url);
|
||||||
|
(detail.replace(upstream_url, &sanitized_url), sanitized_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_upstream_url_text(upstream_url: &str) -> String {
|
||||||
|
if let Ok(mut parsed_url) = reqwest::Url::parse(upstream_url) {
|
||||||
|
parsed_url.set_query(None);
|
||||||
|
parsed_url.set_fragment(None);
|
||||||
|
return parsed_url.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix_offset = upstream_url
|
||||||
|
.char_indices()
|
||||||
|
.find_map(|(offset, character)| matches!(character, '?' | '#').then_some(offset))
|
||||||
|
.unwrap_or(upstream_url.len());
|
||||||
|
upstream_url[..suffix_offset].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn format_wreq_upstream_request_error(err: &wreq::Error) -> String {
|
pub(crate) fn format_wreq_upstream_request_error(err: &wreq::Error) -> String {
|
||||||
let mut kinds = Vec::new();
|
let mut kinds = Vec::new();
|
||||||
if err.is_connect() {
|
if err.is_connect() {
|
||||||
@@ -490,8 +516,12 @@ pub(crate) fn format_wreq_upstream_request_error(err: &wreq::Error) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(uri) = err.uri() {
|
if let Some(uri) = err.uri() {
|
||||||
|
let uri = uri.to_string();
|
||||||
|
let (sanitized_detail, sanitized_uri) =
|
||||||
|
sanitize_upstream_request_error_detail(&detail, &uri);
|
||||||
|
detail = sanitized_detail;
|
||||||
detail.push_str(" [uri=");
|
detail.push_str(" [uri=");
|
||||||
detail.push_str(&uri.to_string());
|
detail.push_str(&sanitized_uri);
|
||||||
detail.push(']');
|
detail.push(']');
|
||||||
}
|
}
|
||||||
if !kinds.is_empty() {
|
if !kinds.is_empty() {
|
||||||
@@ -547,12 +577,60 @@ pub(crate) enum ExecutionRuntimeTransportError {
|
|||||||
BrowserBody(String),
|
BrowserBody(String),
|
||||||
#[error("failed to execute upstream request: {0}")]
|
#[error("failed to execute upstream request: {0}")]
|
||||||
UpstreamRequest(String),
|
UpstreamRequest(String),
|
||||||
|
#[error("upstream response {phase} body exceeds {limit_bytes} bytes")]
|
||||||
|
UpstreamResponseTooLarge {
|
||||||
|
phase: UpstreamResponseBodyPhase,
|
||||||
|
limit_bytes: usize,
|
||||||
|
},
|
||||||
|
#[error("failed to decode upstream response body with content-encoding {encoding}: {message}")]
|
||||||
|
UpstreamResponseDecode { encoding: String, message: String },
|
||||||
#[error("hub relay request failed: {0}")]
|
#[error("hub relay request failed: {0}")]
|
||||||
RelayError(String),
|
RelayError(String),
|
||||||
#[error("upstream response is not valid JSON: {0}")]
|
#[error("upstream response is not valid JSON: {0}")]
|
||||||
InvalidJson(serde_json::Error),
|
InvalidJson(serde_json::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum UpstreamResponseBodyPhase {
|
||||||
|
Wire,
|
||||||
|
Decoded,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for UpstreamResponseBodyPhase {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::Wire => "wire",
|
||||||
|
Self::Decoded => "decoded",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn append_upstream_response_body_chunk(
|
||||||
|
body: &mut Vec<u8>,
|
||||||
|
chunk: &[u8],
|
||||||
|
) -> Result<(), ExecutionRuntimeTransportError> {
|
||||||
|
append_upstream_response_body_chunk_with_limit(
|
||||||
|
body,
|
||||||
|
chunk,
|
||||||
|
crate::headers::max_internal_buffered_body_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_upstream_response_body_chunk_with_limit(
|
||||||
|
body: &mut Vec<u8>,
|
||||||
|
chunk: &[u8],
|
||||||
|
limit_bytes: usize,
|
||||||
|
) -> Result<(), ExecutionRuntimeTransportError> {
|
||||||
|
if body.len() > limit_bytes || chunk.len() > limit_bytes.saturating_sub(body.len()) {
|
||||||
|
return Err(ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||||
|
phase: UpstreamResponseBodyPhase::Wire,
|
||||||
|
limit_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
body.extend_from_slice(chunk);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct RelayRequestMeta {
|
struct RelayRequestMeta {
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
@@ -608,6 +686,7 @@ pub(crate) struct DirectUpstreamStreamExecution {
|
|||||||
pub(crate) provider_api_format: String,
|
pub(crate) provider_api_format: String,
|
||||||
pub(crate) stream_summary_report_context: Value,
|
pub(crate) stream_summary_report_context: Value,
|
||||||
pub(crate) prefetched_body: VecDeque<Result<Bytes, String>>,
|
pub(crate) prefetched_body: VecDeque<Result<Bytes, String>>,
|
||||||
|
pub(crate) stream_precommit_committed: bool,
|
||||||
pub(crate) response: DirectUpstreamResponse,
|
pub(crate) response: DirectUpstreamResponse,
|
||||||
pub(crate) started_at: Instant,
|
pub(crate) started_at: Instant,
|
||||||
pub(crate) stream_first_byte_timeout: Option<Duration>,
|
pub(crate) stream_first_byte_timeout: Option<Duration>,
|
||||||
@@ -654,16 +733,16 @@ impl DirectSyncExecutionRuntime {
|
|||||||
});
|
});
|
||||||
let (body_bytes, stream_ttfb_ms) =
|
let (body_bytes, stream_ttfb_ms) =
|
||||||
response.bytes_with_stream_timeout(plan, started_at).await?;
|
response.bytes_with_stream_timeout(plan, started_at).await?;
|
||||||
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)
|
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
|
||||||
.unwrap_or_else(|| body_bytes.to_vec());
|
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
let upstream_bytes = body_bytes.len() as u64;
|
let upstream_bytes = body_bytes.len() as u64;
|
||||||
|
|
||||||
let body = build_execution_response_body(
|
let body = build_execution_response_body(
|
||||||
&headers,
|
&headers,
|
||||||
&body_bytes,
|
&body_bytes,
|
||||||
&decoded_body_bytes,
|
decoded_body_bytes.as_ref(),
|
||||||
plan.stream,
|
plan.stream,
|
||||||
|
execution_response_body_mode(plan),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(ExecutionResult {
|
Ok(ExecutionResult {
|
||||||
@@ -713,6 +792,7 @@ impl DirectSyncExecutionRuntime {
|
|||||||
provider_api_format: plan.provider_api_format.clone(),
|
provider_api_format: plan.provider_api_format.clone(),
|
||||||
stream_summary_report_context,
|
stream_summary_report_context,
|
||||||
prefetched_body: VecDeque::new(),
|
prefetched_body: VecDeque::new(),
|
||||||
|
stream_precommit_committed: false,
|
||||||
response: response.into_direct_upstream_response(),
|
response: response.into_direct_upstream_response(),
|
||||||
started_at,
|
started_at,
|
||||||
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
|
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
|
||||||
@@ -827,6 +907,7 @@ pub(crate) async fn execute_stream_plan_via_local_tunnel(
|
|||||||
provider_api_format: plan.provider_api_format.clone(),
|
provider_api_format: plan.provider_api_format.clone(),
|
||||||
stream_summary_report_context: build_stream_summary_report_context(plan),
|
stream_summary_report_context: build_stream_summary_report_context(plan),
|
||||||
prefetched_body: VecDeque::new(),
|
prefetched_body: VecDeque::new(),
|
||||||
|
stream_precommit_committed: false,
|
||||||
response: DirectUpstreamResponse::LocalTunnel(response),
|
response: DirectUpstreamResponse::LocalTunnel(response),
|
||||||
started_at,
|
started_at,
|
||||||
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
|
stream_first_byte_timeout: resolve_stream_first_byte_timeout(plan),
|
||||||
@@ -962,8 +1043,7 @@ async fn execute_sync_plan_via_local_tunnel_inner(
|
|||||||
let proxy_timing = execution_header_for_log(&headers, "x-proxy-timing").unwrap_or("-");
|
let proxy_timing = execution_header_for_log(&headers, "x-proxy-timing").unwrap_or("-");
|
||||||
let (body_bytes, stream_ttfb_ms) =
|
let (body_bytes, stream_ttfb_ms) =
|
||||||
collect_local_tunnel_response_body(response, plan, started_at).await?;
|
collect_local_tunnel_response_body(response, plan, started_at).await?;
|
||||||
let decoded_body_bytes =
|
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)?;
|
||||||
decode_response_body_bytes(&headers, &body_bytes).unwrap_or_else(|| body_bytes.clone());
|
|
||||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||||
let upstream_bytes = body_bytes.len() as u64;
|
let upstream_bytes = body_bytes.len() as u64;
|
||||||
if status_code >= 400 {
|
if status_code >= 400 {
|
||||||
@@ -1000,8 +1080,13 @@ async fn execute_sync_plan_via_local_tunnel_inner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body =
|
let body = build_execution_response_body(
|
||||||
build_execution_response_body(&headers, &body_bytes, &decoded_body_bytes, plan.stream)?;
|
&headers,
|
||||||
|
&body_bytes,
|
||||||
|
decoded_body_bytes.as_ref(),
|
||||||
|
plan.stream,
|
||||||
|
execution_response_body_mode(plan),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(ExecutionResult {
|
Ok(ExecutionResult {
|
||||||
request_id: plan.request_id.clone(),
|
request_id: plan.request_id.clone(),
|
||||||
@@ -1044,7 +1129,7 @@ async fn collect_local_tunnel_response_body(
|
|||||||
if plan.stream && first_byte_ms.is_none() && !chunk.is_empty() {
|
if plan.stream && first_byte_ms.is_none() && !chunk.is_empty() {
|
||||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
body_bytes.extend_from_slice(&chunk);
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((body_bytes, first_byte_ms))
|
Ok((body_bytes, first_byte_ms))
|
||||||
@@ -1154,6 +1239,7 @@ async fn send_request_inner(
|
|||||||
let client_select_started_at = Instant::now();
|
let client_select_started_at = Instant::now();
|
||||||
let client = build_client(
|
let client = build_client(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
plan.proxy.as_ref(),
|
plan.proxy.as_ref(),
|
||||||
plan.transport_profile.as_ref(),
|
plan.transport_profile.as_ref(),
|
||||||
@@ -1204,23 +1290,23 @@ impl DirectHttpResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn bytes(self) -> Result<Bytes, ExecutionRuntimeTransportError> {
|
pub(crate) async fn bytes(self) -> Result<Bytes, ExecutionRuntimeTransportError> {
|
||||||
|
let started_at = Instant::now();
|
||||||
match self {
|
match self {
|
||||||
DirectHttpResponse::Reqwest(response) => response.bytes().await.map_err(|err| {
|
DirectHttpResponse::Reqwest(response) => {
|
||||||
ExecutionRuntimeTransportError::UpstreamRequest(format_upstream_request_error(&err))
|
collect_reqwest_stream_body(response, started_at, None)
|
||||||
}),
|
.await
|
||||||
DirectHttpResponse::HyperH2c(response) => response
|
.map(|(body, _)| body)
|
||||||
.into_body()
|
}
|
||||||
.collect()
|
DirectHttpResponse::HyperH2c(response) => {
|
||||||
.await
|
collect_hyper_stream_body(response, started_at, None)
|
||||||
.map(|collected| collected.to_bytes())
|
.await
|
||||||
.map_err(|err| {
|
.map(|(body, _)| body)
|
||||||
ExecutionRuntimeTransportError::UpstreamRequest(format_hyper_error_chain(&err))
|
}
|
||||||
}),
|
DirectHttpResponse::BrowserWreq(response) => {
|
||||||
DirectHttpResponse::BrowserWreq(response) => response.bytes().await.map_err(|err| {
|
collect_wreq_stream_body(response, started_at, None)
|
||||||
ExecutionRuntimeTransportError::BrowserBody(format_wreq_upstream_request_error(
|
.await
|
||||||
&err,
|
.map(|(body, _)| body)
|
||||||
))
|
}
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1308,7 +1394,7 @@ async fn collect_reqwest_stream_body(
|
|||||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
body_bytes.extend_from_slice(&chunk);
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||||
@@ -1338,7 +1424,7 @@ async fn collect_hyper_stream_body(
|
|||||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
body_bytes.extend_from_slice(&chunk);
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||||
@@ -1368,7 +1454,7 @@ async fn collect_wreq_stream_body(
|
|||||||
if first_byte_ms.is_none() && !chunk.is_empty() {
|
if first_byte_ms.is_none() && !chunk.is_empty() {
|
||||||
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
first_byte_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
body_bytes.extend_from_slice(&chunk);
|
append_upstream_response_body_chunk(&mut body_bytes, &chunk)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((Bytes::from(body_bytes), first_byte_ms))
|
Ok((Bytes::from(body_bytes), first_byte_ms))
|
||||||
@@ -2555,6 +2641,7 @@ fn resolve_local_tunnel_node_id(state: &AppState, proxy: Option<&ProxySnapshot>)
|
|||||||
|
|
||||||
fn build_client(
|
fn build_client(
|
||||||
request_url: &str,
|
request_url: &str,
|
||||||
|
key_id: &str,
|
||||||
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
|
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
|
||||||
proxy: Option<&ProxySnapshot>,
|
proxy: Option<&ProxySnapshot>,
|
||||||
transport_profile: Option<&ResolvedTransportProfile>,
|
transport_profile: Option<&ResolvedTransportProfile>,
|
||||||
@@ -2564,6 +2651,7 @@ fn build_client(
|
|||||||
let resolved_proxy_url = resolve_proxy_url(proxy)?;
|
let resolved_proxy_url = resolve_proxy_url(proxy)?;
|
||||||
let cache_key = direct_reqwest_client_cache_key(
|
let cache_key = direct_reqwest_client_cache_key(
|
||||||
request_url,
|
request_url,
|
||||||
|
key_id,
|
||||||
timeouts,
|
timeouts,
|
||||||
resolved_proxy_url,
|
resolved_proxy_url,
|
||||||
transport_profile,
|
transport_profile,
|
||||||
@@ -2610,7 +2698,10 @@ pub(crate) fn prewarm_direct_reqwest_client_cache_for_plan(plan: &ExecutionPlan)
|
|||||||
candidate_id = ?plan.candidate_id,
|
candidate_id = ?plan.candidate_id,
|
||||||
provider_id = %plan.provider_id,
|
provider_id = %plan.provider_id,
|
||||||
endpoint_id = %plan.endpoint_id,
|
endpoint_id = %plan.endpoint_id,
|
||||||
key_id = %plan.key_id,
|
key_partition = ?direct_reqwest_pool_partition(
|
||||||
|
plan.transport_profile.as_ref(),
|
||||||
|
&plan.key_id,
|
||||||
|
),
|
||||||
"gateway direct reqwest client prewarm skipped"
|
"gateway direct reqwest client prewarm skipped"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2638,6 +2729,7 @@ fn try_prewarm_direct_reqwest_client_cache_for_plan(
|
|||||||
let resolved_proxy_url = resolve_proxy_url(plan.proxy.as_ref())?;
|
let resolved_proxy_url = resolve_proxy_url(plan.proxy.as_ref())?;
|
||||||
let cache_key = direct_reqwest_client_cache_key(
|
let cache_key = direct_reqwest_client_cache_key(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
resolved_proxy_url,
|
resolved_proxy_url,
|
||||||
plan.transport_profile.as_ref(),
|
plan.transport_profile.as_ref(),
|
||||||
@@ -2877,6 +2969,7 @@ fn mark_direct_reqwest_client_cache_not_warming(cache_key: &DirectReqwestClientC
|
|||||||
|
|
||||||
fn direct_reqwest_client_cache_key(
|
fn direct_reqwest_client_cache_key(
|
||||||
request_url: &str,
|
request_url: &str,
|
||||||
|
key_id: &str,
|
||||||
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
|
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
|
||||||
proxy_url: Option<String>,
|
proxy_url: Option<String>,
|
||||||
transport_profile: Option<&ResolvedTransportProfile>,
|
transport_profile: Option<&ResolvedTransportProfile>,
|
||||||
@@ -2886,6 +2979,7 @@ fn direct_reqwest_client_cache_key(
|
|||||||
upstream_origin: direct_reqwest_cache_per_origin()
|
upstream_origin: direct_reqwest_cache_per_origin()
|
||||||
.then(|| direct_reqwest_upstream_origin(request_url))
|
.then(|| direct_reqwest_upstream_origin(request_url))
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
pool_partition: direct_reqwest_pool_partition(transport_profile, key_id),
|
||||||
connect_timeout_ms: timeouts.and_then(|timeouts| timeouts.connect_ms),
|
connect_timeout_ms: timeouts.and_then(|timeouts| timeouts.connect_ms),
|
||||||
proxy_url,
|
proxy_url,
|
||||||
follow_redirects: transport_controls.follow_redirects == Some(true),
|
follow_redirects: transport_controls.follow_redirects == Some(true),
|
||||||
@@ -2895,6 +2989,17 @@ fn direct_reqwest_client_cache_key(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn direct_reqwest_pool_partition(
|
||||||
|
transport_profile: Option<&ResolvedTransportProfile>,
|
||||||
|
key_id: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let key_id = key_id.trim();
|
||||||
|
transport_profile
|
||||||
|
.filter(|profile| profile.pool_scope.trim().eq_ignore_ascii_case("key"))
|
||||||
|
.filter(|_| !key_id.is_empty())
|
||||||
|
.map(|_| format!("{:x}", sha2::Sha256::digest(key_id.as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
fn direct_reqwest_cache_per_origin() -> bool {
|
fn direct_reqwest_cache_per_origin() -> bool {
|
||||||
std::env::var(DIRECT_REQWEST_CACHE_PER_ORIGIN_ENV)
|
std::env::var(DIRECT_REQWEST_CACHE_PER_ORIGIN_ENV)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -3719,11 +3824,13 @@ pub(crate) fn build_request_headers(
|
|||||||
}
|
}
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
let normalized_key = key.trim().to_ascii_lowercase();
|
let normalized_key = key.trim().to_ascii_lowercase();
|
||||||
if is_hop_by_hop_header(&normalized_key)
|
if crate::headers::should_skip_request_header(&normalized_key)
|
||||||
|
|| is_hop_by_hop_header(&normalized_key)
|
||||||
|| normalized_key == "content-encoding"
|
|| normalized_key == "content-encoding"
|
||||||
|| normalized_key == EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER
|
|| normalized_key == EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER
|
||||||
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
|
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
|
||||||
|| normalized_key == EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER
|
|| normalized_key == EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER
|
||||||
|
|| normalized_key == EXECUTION_RESPONSE_BODY_MODE_HEADER
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3766,6 +3873,23 @@ fn resolve_execution_transport_controls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execution_response_body_mode(plan: &ExecutionPlan) -> ExecutionResponseBodyMode {
|
||||||
|
if plan.stream
|
||||||
|
|| plan.body.body_bytes_b64.is_none()
|
||||||
|
|| !plan
|
||||||
|
.client_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(plan.provider_api_format.trim())
|
||||||
|
{
|
||||||
|
return ExecutionResponseBodyMode::StructuredJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecutionResponseBodyMode::from_header_value(execution_transport_header_value(
|
||||||
|
&plan.headers,
|
||||||
|
EXECUTION_RESPONSE_BODY_MODE_HEADER,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn execution_transport_header_value<'a>(
|
fn execution_transport_header_value<'a>(
|
||||||
headers: &'a BTreeMap<String, String>,
|
headers: &'a BTreeMap<String, String>,
|
||||||
target: &str,
|
target: &str,
|
||||||
@@ -3844,10 +3968,22 @@ fn execution_log_url_host(url: &str) -> String {
|
|||||||
.unwrap_or_else(|| "-".to_string())
|
.unwrap_or_else(|| "-".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decode_response_body_bytes(
|
pub(crate) fn decode_response_body_bytes<'a>(
|
||||||
headers: &BTreeMap<String, String>,
|
headers: &BTreeMap<String, String>,
|
||||||
body_bytes: &[u8],
|
body_bytes: &'a [u8],
|
||||||
) -> Option<Vec<u8>> {
|
) -> Result<Cow<'a, [u8]>, ExecutionRuntimeTransportError> {
|
||||||
|
decode_response_body_bytes_with_limit(
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
crate::headers::max_internal_buffered_body_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_response_body_bytes_with_limit<'a>(
|
||||||
|
headers: &BTreeMap<String, String>,
|
||||||
|
body_bytes: &'a [u8],
|
||||||
|
limit_bytes: usize,
|
||||||
|
) -> Result<Cow<'a, [u8]>, ExecutionRuntimeTransportError> {
|
||||||
let encoding = headers
|
let encoding = headers
|
||||||
.get("content-encoding")
|
.get("content-encoding")
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
@@ -3857,20 +3993,43 @@ pub(crate) fn decode_response_body_bytes(
|
|||||||
match encoding.as_deref() {
|
match encoding.as_deref() {
|
||||||
Some("gzip") => {
|
Some("gzip") => {
|
||||||
let mut decoder = GzDecoder::new(body_bytes);
|
let mut decoder = GzDecoder::new(body_bytes);
|
||||||
let mut out = Vec::new();
|
read_upstream_response_decoder_with_limit("gzip", &mut decoder, limit_bytes)
|
||||||
decoder.read_to_end(&mut out).ok()?;
|
.map(Cow::Owned)
|
||||||
Some(out)
|
|
||||||
}
|
}
|
||||||
Some("deflate") => {
|
Some("deflate") => {
|
||||||
let mut decoder = DeflateDecoder::new(body_bytes);
|
let mut decoder = DeflateDecoder::new(body_bytes);
|
||||||
let mut out = Vec::new();
|
read_upstream_response_decoder_with_limit("deflate", &mut decoder, limit_bytes)
|
||||||
decoder.read_to_end(&mut out).ok()?;
|
.map(Cow::Owned)
|
||||||
Some(out)
|
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => Ok(Cow::Borrowed(body_bytes)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_upstream_response_decoder_with_limit(
|
||||||
|
encoding: &str,
|
||||||
|
decoder: &mut impl Read,
|
||||||
|
limit_bytes: usize,
|
||||||
|
) -> Result<Vec<u8>, ExecutionRuntimeTransportError> {
|
||||||
|
let read_limit = u64::try_from(limit_bytes)
|
||||||
|
.unwrap_or(u64::MAX)
|
||||||
|
.saturating_add(1);
|
||||||
|
let mut limited = decoder.take(read_limit);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
limited.read_to_end(&mut out).map_err(|error| {
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseDecode {
|
||||||
|
encoding: encoding.to_string(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if out.len() > limit_bytes {
|
||||||
|
return Err(ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||||
|
phase: UpstreamResponseBodyPhase::Decoded,
|
||||||
|
limit_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
|
pub(crate) fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
|
||||||
let content_type = headers
|
let content_type = headers
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
@@ -3893,6 +4052,7 @@ pub(crate) fn build_execution_response_body(
|
|||||||
body_bytes: &[u8],
|
body_bytes: &[u8],
|
||||||
decoded_body_bytes: &[u8],
|
decoded_body_bytes: &[u8],
|
||||||
stream: bool,
|
stream: bool,
|
||||||
|
response_body_mode: ExecutionResponseBodyMode,
|
||||||
) -> Result<Option<ResponseBody>, ExecutionRuntimeTransportError> {
|
) -> Result<Option<ResponseBody>, ExecutionRuntimeTransportError> {
|
||||||
if body_bytes.is_empty() {
|
if body_bytes.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -3903,7 +4063,8 @@ pub(crate) fn build_execution_response_body(
|
|||||||
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
|
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
|
||||||
return Ok(Some(ResponseBody {
|
return Ok(Some(ResponseBody {
|
||||||
json_body: Some(body_json),
|
json_body: Some(body_json),
|
||||||
body_bytes_b64: None,
|
body_bytes_b64: (response_body_mode == ExecutionResponseBodyMode::PreserveBytes)
|
||||||
|
.then(|| base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3932,12 +4093,13 @@ pub(crate) fn build_execution_response_body(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::Read;
|
use std::io::{Read, Write};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||||
|
|
||||||
use aether_contracts::{
|
use aether_contracts::{
|
||||||
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile,
|
ExecutionPlan, ExecutionResponseBodyMode, ExecutionTimeouts, ProxySnapshot, RequestBody,
|
||||||
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
ResolvedTransportProfile, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
||||||
|
EXECUTION_REQUEST_HTTP1_ONLY_HEADER, EXECUTION_RESPONSE_BODY_MODE_HEADER,
|
||||||
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO,
|
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO,
|
||||||
TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
|
TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
|
||||||
};
|
};
|
||||||
@@ -3956,13 +4118,14 @@ mod tests {
|
|||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_browser_wreq_client, build_client, build_direct_tunnel_request_meta,
|
append_upstream_response_body_chunk_with_limit, build_browser_wreq_client, build_client,
|
||||||
build_execution_response_body, build_request_headers, execute_sync_plan,
|
build_direct_tunnel_request_meta, build_execution_response_body, build_request_headers,
|
||||||
|
decode_response_body_bytes_with_limit, execute_sync_plan, execution_response_body_mode,
|
||||||
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
|
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
|
||||||
record_manual_proxy_request_success, record_manual_proxy_stream_error,
|
record_manual_proxy_request_success, record_manual_proxy_stream_error,
|
||||||
resolve_execution_transport_controls, resolve_non_stream_total_timeout,
|
resolve_execution_transport_controls, resolve_non_stream_total_timeout,
|
||||||
resolve_stream_first_byte_timeout, response_body_is_json, DirectSyncExecutionRuntime,
|
resolve_stream_first_byte_timeout, response_body_is_json, DirectSyncExecutionRuntime,
|
||||||
ExecutionRuntimeTransportError, ExecutionTransportControls,
|
ExecutionRuntimeTransportError, ExecutionTransportControls, UpstreamResponseBodyPhase,
|
||||||
};
|
};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_VIA_TOKEN,
|
EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_VIA_TOKEN,
|
||||||
@@ -3976,6 +4139,106 @@ mod tests {
|
|||||||
|
|
||||||
const LOCAL_HTTP_SUCCESS_TIMEOUT_MS: u64 = 15_000;
|
const LOCAL_HTTP_SUCCESS_TIMEOUT_MS: u64 = 15_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_error_url_sanitization_removes_secrets_everywhere() {
|
||||||
|
let upstream_url =
|
||||||
|
"https://api.example.test/v1/messages?key=query-secret&alt=sse#fragment-secret";
|
||||||
|
let detail = format!(
|
||||||
|
"error sending request for url ({upstream_url}); source repeated {upstream_url}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (sanitized_detail, sanitized_url) =
|
||||||
|
super::sanitize_upstream_request_error_detail(&detail, upstream_url);
|
||||||
|
|
||||||
|
assert_eq!(sanitized_url, "https://api.example.test/v1/messages");
|
||||||
|
assert_eq!(
|
||||||
|
sanitized_detail,
|
||||||
|
"error sending request for url (https://api.example.test/v1/messages); source repeated https://api.example.test/v1/messages"
|
||||||
|
);
|
||||||
|
assert!(!sanitized_detail.contains("query-secret"));
|
||||||
|
assert!(!sanitized_detail.contains("fragment-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_header_materialization_strips_all_aether_internal_headers() {
|
||||||
|
let headers = BTreeMap::from([
|
||||||
|
("authorization".to_string(), "Bearer upstream".to_string()),
|
||||||
|
("x-aether-grok-runtime".to_string(), "1".to_string()),
|
||||||
|
("x-aether-future-control".to_string(), "private".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let materialized = build_request_headers(&headers, None, false)
|
||||||
|
.expect("provider request headers should materialize");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
materialized
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("Bearer upstream")
|
||||||
|
);
|
||||||
|
assert!(!materialized.contains_key("x-aether-grok-runtime"));
|
||||||
|
assert!(!materialized.contains_key("x-aether-future-control"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_response_wire_limit_allows_exact_body_and_rejects_next_byte() {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
append_upstream_response_body_chunk_with_limit(&mut body, b"1234", 5)
|
||||||
|
.expect("chunk below limit should append");
|
||||||
|
append_upstream_response_body_chunk_with_limit(&mut body, b"5", 5)
|
||||||
|
.expect("body exactly at limit should append");
|
||||||
|
|
||||||
|
let error = append_upstream_response_body_chunk_with_limit(&mut body, b"6", 5)
|
||||||
|
.expect_err("body above limit should fail");
|
||||||
|
|
||||||
|
assert_eq!(body, b"12345");
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||||
|
phase: UpstreamResponseBodyPhase::Wire,
|
||||||
|
limit_bytes: 5,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_response_gzip_decode_limit_rejects_decompression_bomb() {
|
||||||
|
let payload = vec![b'x'; 9];
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder
|
||||||
|
.write_all(&payload)
|
||||||
|
.expect("gzip payload should encode");
|
||||||
|
let encoded = encoder.finish().expect("gzip payload should finish");
|
||||||
|
let headers = BTreeMap::from([("content-encoding".to_string(), "gzip".to_string())]);
|
||||||
|
|
||||||
|
let error = decode_response_body_bytes_with_limit(&headers, &encoded, 8)
|
||||||
|
.expect_err("decoded body above limit should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
ExecutionRuntimeTransportError::UpstreamResponseTooLarge {
|
||||||
|
phase: UpstreamResponseBodyPhase::Decoded,
|
||||||
|
limit_bytes: 8,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_response_gzip_decode_limit_allows_exact_body() {
|
||||||
|
let payload = b"12345678";
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder
|
||||||
|
.write_all(payload)
|
||||||
|
.expect("gzip payload should encode");
|
||||||
|
let encoded = encoder.finish().expect("gzip payload should finish");
|
||||||
|
let headers = BTreeMap::from([("content-encoding".to_string(), "gzip".to_string())]);
|
||||||
|
|
||||||
|
let decoded = decode_response_body_bytes_with_limit(&headers, &encoded, payload.len())
|
||||||
|
.expect("decoded body exactly at limit should pass");
|
||||||
|
|
||||||
|
assert_eq!(decoded.as_ref(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gateway_frontdoor_self_loop_guard_matches_loopback_public_ai_route() {
|
fn gateway_frontdoor_self_loop_guard_matches_loopback_public_ai_route() {
|
||||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
|
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||||
@@ -4034,6 +4297,7 @@ mod tests {
|
|||||||
for proxy_url in ["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1080"] {
|
for proxy_url in ["socks5://127.0.0.1:1080", "socks5h://127.0.0.1:1080"] {
|
||||||
build_client(
|
build_client(
|
||||||
"https://api.example.test/v1/chat/completions",
|
"https://api.example.test/v1/chat/completions",
|
||||||
|
"key-test",
|
||||||
Some(&timeouts),
|
Some(&timeouts),
|
||||||
Some(&aether_contracts::ProxySnapshot {
|
Some(&aether_contracts::ProxySnapshot {
|
||||||
enabled: Some(true),
|
enabled: Some(true),
|
||||||
@@ -4103,6 +4367,7 @@ mod tests {
|
|||||||
|
|
||||||
let left = super::direct_reqwest_client_cache_key(
|
let left = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
Some(&timeouts),
|
Some(&timeouts),
|
||||||
None,
|
None,
|
||||||
Some(&h2c_profile),
|
Some(&h2c_profile),
|
||||||
@@ -4110,6 +4375,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let right = super::direct_reqwest_client_cache_key(
|
let right = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/responses",
|
"http://127.0.0.1:18184/v1/responses",
|
||||||
|
"key-1",
|
||||||
Some(&timeouts),
|
Some(&timeouts),
|
||||||
None,
|
None,
|
||||||
Some(&same_h2c_profile),
|
Some(&same_h2c_profile),
|
||||||
@@ -4117,6 +4383,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let different_mode = super::direct_reqwest_client_cache_key(
|
let different_mode = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
Some(&timeouts),
|
Some(&timeouts),
|
||||||
None,
|
None,
|
||||||
Some(&http1_profile),
|
Some(&http1_profile),
|
||||||
@@ -4124,6 +4391,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let different_proxy = super::direct_reqwest_client_cache_key(
|
let different_proxy = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
Some(&timeouts),
|
Some(&timeouts),
|
||||||
Some("http://127.0.0.1:8080".into()),
|
Some("http://127.0.0.1:8080".into()),
|
||||||
Some(&h2c_profile),
|
Some(&h2c_profile),
|
||||||
@@ -4138,6 +4406,72 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_reqwest_client_cache_key_partitions_key_scoped_pools_by_hashed_key_id() {
|
||||||
|
let profile = ResolvedTransportProfile {
|
||||||
|
profile_id: "key-scoped-profile".into(),
|
||||||
|
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.into(),
|
||||||
|
http_mode: TRANSPORT_HTTP_MODE_AUTO.into(),
|
||||||
|
pool_scope: " key ".into(),
|
||||||
|
header_fingerprint: None,
|
||||||
|
extra: None,
|
||||||
|
};
|
||||||
|
let first_key_id = "plain-key-identity-alpha";
|
||||||
|
let second_key_id = "plain-key-identity-beta";
|
||||||
|
let cache_key = |key_id| {
|
||||||
|
super::direct_reqwest_client_cache_key(
|
||||||
|
"https://api.example.test/v1/messages",
|
||||||
|
key_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&profile),
|
||||||
|
ExecutionTransportControls::default(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = cache_key(first_key_id);
|
||||||
|
let first_key_id_with_whitespace = format!(" {first_key_id} ");
|
||||||
|
let first_with_whitespace = cache_key(&first_key_id_with_whitespace);
|
||||||
|
let second = cache_key(second_key_id);
|
||||||
|
let empty = cache_key(" ");
|
||||||
|
|
||||||
|
assert_eq!(first, first_with_whitespace);
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert_eq!(first.pool_partition.as_deref().map(str::len), Some(64));
|
||||||
|
assert!(empty.pool_partition.is_none());
|
||||||
|
let debug = format!("{first:?} {second:?}");
|
||||||
|
assert!(!debug.contains(first_key_id));
|
||||||
|
assert!(!debug.contains(second_key_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direct_reqwest_client_cache_key_shares_non_key_scoped_pools() {
|
||||||
|
let profile = ResolvedTransportProfile {
|
||||||
|
profile_id: "provider-scoped-profile".into(),
|
||||||
|
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.into(),
|
||||||
|
http_mode: TRANSPORT_HTTP_MODE_AUTO.into(),
|
||||||
|
pool_scope: "provider".into(),
|
||||||
|
header_fingerprint: None,
|
||||||
|
extra: None,
|
||||||
|
};
|
||||||
|
let cache_key = |key_id| {
|
||||||
|
super::direct_reqwest_client_cache_key(
|
||||||
|
"https://api.example.test/v1/messages",
|
||||||
|
key_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(&profile),
|
||||||
|
ExecutionTransportControls::default(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = cache_key("plain-key-identity-alpha");
|
||||||
|
let second = cache_key("plain-key-identity-beta");
|
||||||
|
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert!(first.pool_partition.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn direct_reqwest_client_cache_key_splits_origin_only_when_enabled() {
|
fn direct_reqwest_client_cache_key_splits_origin_only_when_enabled() {
|
||||||
let _guard = direct_reqwest_env_lock();
|
let _guard = direct_reqwest_env_lock();
|
||||||
@@ -4152,6 +4486,7 @@ mod tests {
|
|||||||
|
|
||||||
let shared_left = super::direct_reqwest_client_cache_key(
|
let shared_left = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4159,6 +4494,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let shared_right = super::direct_reqwest_client_cache_key(
|
let shared_right = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18185/v1/chat/completions",
|
"http://127.0.0.1:18185/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4169,6 +4505,7 @@ mod tests {
|
|||||||
let _per_origin = set_test_env_var(super::DIRECT_REQWEST_CACHE_PER_ORIGIN_ENV, "true");
|
let _per_origin = set_test_env_var(super::DIRECT_REQWEST_CACHE_PER_ORIGIN_ENV, "true");
|
||||||
let split_left = super::direct_reqwest_client_cache_key(
|
let split_left = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4176,6 +4513,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let split_right = super::direct_reqwest_client_cache_key(
|
let split_right = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18185/v1/chat/completions",
|
"http://127.0.0.1:18185/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4201,6 +4539,7 @@ mod tests {
|
|||||||
|
|
||||||
let auto_key = super::direct_reqwest_client_cache_key(
|
let auto_key = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&auto_profile),
|
Some(&auto_profile),
|
||||||
@@ -4208,6 +4547,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let h2c_key = super::direct_reqwest_client_cache_key(
|
let h2c_key = super::direct_reqwest_client_cache_key(
|
||||||
"http://127.0.0.1:18184/v1/chat/completions",
|
"http://127.0.0.1:18184/v1/chat/completions",
|
||||||
|
"key-1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&h2c_profile),
|
Some(&h2c_profile),
|
||||||
@@ -4547,6 +4887,7 @@ mod tests {
|
|||||||
|
|
||||||
let cache_key = super::direct_reqwest_client_cache_key(
|
let cache_key = super::direct_reqwest_client_cache_key(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4610,6 +4951,7 @@ mod tests {
|
|||||||
|
|
||||||
let cache_key = super::direct_reqwest_client_cache_key(
|
let cache_key = super::direct_reqwest_client_cache_key(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4664,6 +5006,7 @@ mod tests {
|
|||||||
|
|
||||||
let cache_key = super::direct_reqwest_client_cache_key(
|
let cache_key = super::direct_reqwest_client_cache_key(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -4782,6 +5125,57 @@ mod tests {
|
|||||||
.is_none());
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_body_mode_control_header_is_never_forwarded_upstream() {
|
||||||
|
let headers = BTreeMap::from([
|
||||||
|
("content-type".into(), "application/json".into()),
|
||||||
|
(
|
||||||
|
EXECUTION_RESPONSE_BODY_MODE_HEADER.into(),
|
||||||
|
ExecutionResponseBodyMode::PreserveBytes
|
||||||
|
.as_str()
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let forwarded = build_request_headers(&headers, None, true)
|
||||||
|
.expect("headers should build after stripping internal controls");
|
||||||
|
|
||||||
|
assert!(forwarded.get("content-type").is_some());
|
||||||
|
assert!(forwarded.get(EXECUTION_RESPONSE_BODY_MODE_HEADER).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_body_mode_requires_same_format_raw_sync_plan() {
|
||||||
|
let mut plan = tunnel_timeout_plan(false);
|
||||||
|
plan.headers.insert(
|
||||||
|
EXECUTION_RESPONSE_BODY_MODE_HEADER.to_string(),
|
||||||
|
ExecutionResponseBodyMode::PreserveBytes
|
||||||
|
.as_str()
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
execution_response_body_mode(&plan),
|
||||||
|
ExecutionResponseBodyMode::StructuredJson
|
||||||
|
);
|
||||||
|
|
||||||
|
plan.body = RequestBody {
|
||||||
|
json_body: None,
|
||||||
|
body_bytes_b64: Some("e30=".to_string()),
|
||||||
|
body_ref: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
execution_response_body_mode(&plan),
|
||||||
|
ExecutionResponseBodyMode::PreserveBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
plan.provider_api_format = "claude:messages".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
execution_response_body_mode(&plan),
|
||||||
|
ExecutionResponseBodyMode::StructuredJson
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tunnel_request_meta_uses_total_timeout_for_non_stream_requests() {
|
fn tunnel_request_meta_uses_total_timeout_for_non_stream_requests() {
|
||||||
let plan = tunnel_timeout_plan(false);
|
let plan = tunnel_timeout_plan(false);
|
||||||
@@ -6465,6 +6859,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let cache_key = super::direct_reqwest_client_cache_key(
|
let cache_key = super::direct_reqwest_client_cache_key(
|
||||||
&plan.url,
|
&plan.url,
|
||||||
|
&plan.key_id,
|
||||||
plan.timeouts.as_ref(),
|
plan.timeouts.as_ref(),
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -6646,6 +7041,7 @@ mod tests {
|
|||||||
|
|
||||||
let error = match build_client(
|
let error = match build_client(
|
||||||
"https://api.example.test/v1/chat/completions",
|
"https://api.example.test/v1/chat/completions",
|
||||||
|
"key-test",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(&profile),
|
Some(&profile),
|
||||||
@@ -6673,6 +7069,51 @@ mod tests {
|
|||||||
assert!(!response_body_is_json(&headers, &body));
|
assert!(!response_body_is_json(&headers, &body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_json_response_does_not_duplicate_body_bytes() {
|
||||||
|
let headers =
|
||||||
|
BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
||||||
|
let body_bytes = br#"{ "unknown": true, "ok": true }"#;
|
||||||
|
|
||||||
|
let body = build_execution_response_body(
|
||||||
|
&headers,
|
||||||
|
body_bytes,
|
||||||
|
body_bytes,
|
||||||
|
false,
|
||||||
|
ExecutionResponseBodyMode::StructuredJson,
|
||||||
|
)
|
||||||
|
.expect("body should build")
|
||||||
|
.expect("body should be present");
|
||||||
|
|
||||||
|
assert!(body.json_body.is_some());
|
||||||
|
assert!(body.body_bytes_b64.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserve_bytes_json_response_keeps_parsed_and_wire_representations() {
|
||||||
|
let headers =
|
||||||
|
BTreeMap::from([("content-type".to_string(), "application/json".to_string())]);
|
||||||
|
let body_bytes = br#"{ "unknown": true, "ok": true }"#;
|
||||||
|
|
||||||
|
let body = build_execution_response_body(
|
||||||
|
&headers,
|
||||||
|
body_bytes,
|
||||||
|
body_bytes,
|
||||||
|
false,
|
||||||
|
ExecutionResponseBodyMode::PreserveBytes,
|
||||||
|
)
|
||||||
|
.expect("body should build")
|
||||||
|
.expect("body should be present");
|
||||||
|
|
||||||
|
assert_eq!(body.json_body, Some(json!({"unknown": true, "ok": true})));
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(body.body_bytes_b64.expect("wire bytes should be present"))
|
||||||
|
.expect("wire body should decode"),
|
||||||
|
body_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connect_json_error_response_is_decoded_for_stream_sync_body() {
|
fn connect_json_error_response_is_decoded_for_stream_sync_body() {
|
||||||
let headers = BTreeMap::from([(
|
let headers = BTreeMap::from([(
|
||||||
@@ -6684,9 +7125,15 @@ mod tests {
|
|||||||
body_bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
body_bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||||
body_bytes.extend_from_slice(payload);
|
body_bytes.extend_from_slice(payload);
|
||||||
|
|
||||||
let body = build_execution_response_body(&headers, &body_bytes, &body_bytes, true)
|
let body = build_execution_response_body(
|
||||||
.expect("body should build")
|
&headers,
|
||||||
.expect("body should be present");
|
&body_bytes,
|
||||||
|
&body_bytes,
|
||||||
|
true,
|
||||||
|
ExecutionResponseBodyMode::StructuredJson,
|
||||||
|
)
|
||||||
|
.expect("body should build")
|
||||||
|
.expect("body should be present");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body.json_body
|
body.json_body
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use aether_ai_serving::{
|
use aether_ai_serving::{
|
||||||
run_ai_attempt_loop, AiAttemptLoopOutcome, AiAttemptLoopPort, AiExecutionAttempt,
|
run_ai_attempt_loop, AiAttemptExecutionOutcome, AiAttemptLoopOutcome, AiAttemptLoopPort,
|
||||||
|
AiAttemptRetryScope, AiExecutionAttempt,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||||
use aether_runtime::ConcurrencyPermit;
|
use aether_runtime::ConcurrencyPermit;
|
||||||
@@ -18,8 +19,13 @@ use tracing::{debug, warn, Instrument};
|
|||||||
use crate::ai_serving::LocalExecutionAttemptSource;
|
use crate::ai_serving::LocalExecutionAttemptSource;
|
||||||
use crate::clock::current_unix_ms;
|
use crate::clock::current_unix_ms;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::execution_runtime::{execute_execution_runtime_stream, execute_execution_runtime_sync};
|
use crate::execution_runtime::{
|
||||||
use crate::executor::{build_local_execution_exhaustion, LocalExecutionRequestOutcome};
|
execute_execution_runtime_stream_with_retry_scope,
|
||||||
|
execute_execution_runtime_sync_with_retry_scope,
|
||||||
|
};
|
||||||
|
use crate::executor::{
|
||||||
|
build_local_execution_exhaustion, mark_deferred_upstream_response, LocalExecutionRequestOutcome,
|
||||||
|
};
|
||||||
use crate::handlers::shared::provider_pool::release_admin_provider_pool_key_lease;
|
use crate::handlers::shared::provider_pool::release_admin_provider_pool_key_lease;
|
||||||
use crate::log_ids::short_request_id;
|
use crate::log_ids::short_request_id;
|
||||||
use crate::orchestration::{
|
use crate::orchestration::{
|
||||||
@@ -124,6 +130,9 @@ where
|
|||||||
AiAttemptLoopOutcome::Responded(response) => {
|
AiAttemptLoopOutcome::Responded(response) => {
|
||||||
Ok(LocalExecutionRequestOutcome::responded(response))
|
Ok(LocalExecutionRequestOutcome::responded(response))
|
||||||
}
|
}
|
||||||
|
AiAttemptLoopOutcome::Deferred(response) => Ok(
|
||||||
|
LocalExecutionRequestOutcome::responded(mark_deferred_upstream_response(response)),
|
||||||
|
),
|
||||||
AiAttemptLoopOutcome::Exhausted(exhaustion) => {
|
AiAttemptLoopOutcome::Exhausted(exhaustion) => {
|
||||||
Ok(LocalExecutionRequestOutcome::Exhausted(exhaustion))
|
Ok(LocalExecutionRequestOutcome::Exhausted(exhaustion))
|
||||||
}
|
}
|
||||||
@@ -251,7 +260,10 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_attempt(&self, attempt: &T) -> Result<Option<Self::Response>, Self::Error> {
|
async fn execute_attempt(
|
||||||
|
&self,
|
||||||
|
attempt: &T,
|
||||||
|
) -> Result<AiAttemptExecutionOutcome<Self::Response>, Self::Error> {
|
||||||
let plan = attempt.execution_plan();
|
let plan = attempt.execution_plan();
|
||||||
let report_context = attempt.report_context();
|
let report_context = attempt.report_context();
|
||||||
if let Some(response) = execution_plan_balance_capacity_response(
|
if let Some(response) = execution_plan_balance_capacity_response(
|
||||||
@@ -263,12 +275,12 @@ where
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(response));
|
return Ok(AiAttemptExecutionOutcome::Responded(response));
|
||||||
}
|
}
|
||||||
prewarm_direct_reqwest_candidate_client(plan);
|
prewarm_direct_reqwest_candidate_client(plan);
|
||||||
let _permit = acquire_upstream_execution_gate(self.state, self.trace_id).await?;
|
let _permit = acquire_upstream_execution_gate(self.state, self.trace_id).await?;
|
||||||
let upstream_execution_gate_held_started_at = std::time::Instant::now();
|
let upstream_execution_gate_held_started_at = std::time::Instant::now();
|
||||||
let mut response = execute_execution_runtime_sync(
|
let mut execution = execute_execution_runtime_sync_with_retry_scope(
|
||||||
self.state,
|
self.state,
|
||||||
self.parts.uri.path(),
|
self.parts.uri.path(),
|
||||||
plan.clone(),
|
plan.clone(),
|
||||||
@@ -285,10 +297,18 @@ where
|
|||||||
.elapsed()
|
.elapsed()
|
||||||
.as_millis() as u64,
|
.as_millis() as u64,
|
||||||
);
|
);
|
||||||
if let Some(response) = response.as_mut() {
|
match &mut execution {
|
||||||
attach_redaction_execution_candidate(response, plan.candidate_id.as_deref());
|
AiAttemptExecutionOutcome::Responded(response)
|
||||||
|
| AiAttemptExecutionOutcome::Retry {
|
||||||
|
fallback_response: Some(response),
|
||||||
|
..
|
||||||
|
} => attach_redaction_execution_candidate(response, plan.candidate_id.as_deref()),
|
||||||
|
AiAttemptExecutionOutcome::Retry {
|
||||||
|
fallback_response: None,
|
||||||
|
..
|
||||||
|
} => {}
|
||||||
}
|
}
|
||||||
Ok(response)
|
Ok(execution)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_unused_attempts(&self, attempts: Vec<T>) -> Result<(), Self::Error> {
|
async fn mark_unused_attempts(&self, attempts: Vec<T>) -> Result<(), Self::Error> {
|
||||||
@@ -389,6 +409,9 @@ where
|
|||||||
AiAttemptLoopOutcome::Responded(response) => {
|
AiAttemptLoopOutcome::Responded(response) => {
|
||||||
Ok(LocalExecutionRequestOutcome::responded(response))
|
Ok(LocalExecutionRequestOutcome::responded(response))
|
||||||
}
|
}
|
||||||
|
AiAttemptLoopOutcome::Deferred(response) => Ok(
|
||||||
|
LocalExecutionRequestOutcome::responded(mark_deferred_upstream_response(response)),
|
||||||
|
),
|
||||||
AiAttemptLoopOutcome::Exhausted(exhaustion) => {
|
AiAttemptLoopOutcome::Exhausted(exhaustion) => {
|
||||||
Ok(LocalExecutionRequestOutcome::Exhausted(exhaustion))
|
Ok(LocalExecutionRequestOutcome::Exhausted(exhaustion))
|
||||||
}
|
}
|
||||||
@@ -760,6 +783,7 @@ where
|
|||||||
Attempt: AiExecutionAttempt + Send + Sync + 'static,
|
Attempt: AiExecutionAttempt + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let mut last_attempted = None;
|
let mut last_attempted = None;
|
||||||
|
let mut fallback_response = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let next_started_at = std::time::Instant::now();
|
let next_started_at = std::time::Instant::now();
|
||||||
@@ -781,8 +805,8 @@ where
|
|||||||
}
|
}
|
||||||
port.record_attempt_started(&attempt).await?;
|
port.record_attempt_started(&attempt).await?;
|
||||||
let execute_started_at = std::time::Instant::now();
|
let execute_started_at = std::time::Instant::now();
|
||||||
let response = match port.execute_attempt(&attempt).await {
|
let execution = match port.execute_attempt(&attempt).await {
|
||||||
Ok(response) => response,
|
Ok(execution) => execution,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let remaining = source.drain_execution_attempts().await?;
|
let remaining = source.drain_execution_attempts().await?;
|
||||||
port.mark_unused_attempts(remaining).await?;
|
port.mark_unused_attempts(remaining).await?;
|
||||||
@@ -793,15 +817,26 @@ where
|
|||||||
"stream_candidate_execute",
|
"stream_candidate_execute",
|
||||||
execute_started_at.elapsed().as_millis() as u64,
|
execute_started_at.elapsed().as_millis() as u64,
|
||||||
);
|
);
|
||||||
if let Some(response) = response {
|
match execution {
|
||||||
let remaining = source.drain_execution_attempts().await?;
|
AiAttemptExecutionOutcome::Responded(response) => {
|
||||||
let unused_started_at = std::time::Instant::now();
|
let remaining = source.drain_execution_attempts().await?;
|
||||||
port.mark_unused_attempts(remaining).await?;
|
let unused_started_at = std::time::Instant::now();
|
||||||
observe_gateway_stage_ms(
|
port.mark_unused_attempts(remaining).await?;
|
||||||
"stream_candidate_unused",
|
observe_gateway_stage_ms(
|
||||||
unused_started_at.elapsed().as_millis() as u64,
|
"stream_candidate_unused",
|
||||||
);
|
unused_started_at.elapsed().as_millis() as u64,
|
||||||
return Ok(LocalExecutionRequestOutcome::responded(response));
|
);
|
||||||
|
return Ok(LocalExecutionRequestOutcome::responded(response));
|
||||||
|
}
|
||||||
|
AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope,
|
||||||
|
fallback_response: attempt_fallback_response,
|
||||||
|
} => {
|
||||||
|
if attempt_fallback_response.is_some() {
|
||||||
|
fallback_response = attempt_fallback_response;
|
||||||
|
}
|
||||||
|
apply_attempt_retry_scope(source, &attempt, scope).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
port.record_attempt_failed(&attempt).await?;
|
port.record_attempt_failed(&attempt).await?;
|
||||||
@@ -816,6 +851,12 @@ where
|
|||||||
last_attempted = Some((attempt.execution_plan().clone(), attempt.report_context()));
|
last_attempted = Some((attempt.execution_plan().clone(), attempt.report_context()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(response) = fallback_response {
|
||||||
|
return Ok(LocalExecutionRequestOutcome::responded(
|
||||||
|
mark_deferred_upstream_response(response),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let Some((last_plan, last_report_context)) = last_attempted else {
|
let Some((last_plan, last_report_context)) = last_attempted else {
|
||||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
@@ -826,6 +867,24 @@ where
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_attempt_retry_scope<Source, Attempt>(
|
||||||
|
source: &mut Source,
|
||||||
|
attempt: &Attempt,
|
||||||
|
scope: AiAttemptRetryScope,
|
||||||
|
) -> Result<(), GatewayError>
|
||||||
|
where
|
||||||
|
Source: LocalExecutionAttemptSource<Attempt>,
|
||||||
|
Attempt: AiExecutionAttempt,
|
||||||
|
{
|
||||||
|
let plan = attempt.execution_plan();
|
||||||
|
match scope {
|
||||||
|
AiAttemptRetryScope::Candidate => Ok(()),
|
||||||
|
AiAttemptRetryScope::Credential => source.skip_credential(plan.key_id.as_str()).await,
|
||||||
|
AiAttemptRetryScope::Endpoint => source.skip_endpoint(plan.endpoint_id.as_str()).await,
|
||||||
|
AiAttemptRetryScope::Provider => source.skip_provider(plan.provider_id.as_str()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn next_execution_attempt_with_timeout<Source, Attempt>(
|
async fn next_execution_attempt_with_timeout<Source, Attempt>(
|
||||||
source: &mut Source,
|
source: &mut Source,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
@@ -901,7 +960,10 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_attempt(&self, attempt: &T) -> Result<Option<Self::Response>, Self::Error> {
|
async fn execute_attempt(
|
||||||
|
&self,
|
||||||
|
attempt: &T,
|
||||||
|
) -> Result<AiAttemptExecutionOutcome<Self::Response>, Self::Error> {
|
||||||
let plan = attempt.execution_plan();
|
let plan = attempt.execution_plan();
|
||||||
let report_context = attempt.report_context();
|
let report_context = attempt.report_context();
|
||||||
let candidate_index = parse_request_candidate_report_context(report_context.as_ref())
|
let candidate_index = parse_request_candidate_report_context(report_context.as_ref())
|
||||||
@@ -931,7 +993,7 @@ where
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(response));
|
return Ok(AiAttemptExecutionOutcome::Responded(response));
|
||||||
}
|
}
|
||||||
prewarm_direct_reqwest_candidate_client(plan);
|
prewarm_direct_reqwest_candidate_client(plan);
|
||||||
// The attempt owns the canonical report context. Borrow it for the
|
// The attempt owns the canonical report context. Borrow it for the
|
||||||
@@ -951,14 +1013,14 @@ where
|
|||||||
let execution_decision = self.decision.clone();
|
let execution_decision = self.decision.clone();
|
||||||
let execution_report_kind = attempt.report_kind();
|
let execution_report_kind = attempt.report_kind();
|
||||||
let execution_plan = plan.clone();
|
let execution_plan = plan.clone();
|
||||||
let mut response = execute_stream_candidate_with_watchdog(
|
let mut execution = execute_stream_candidate_with_watchdog(
|
||||||
self.state,
|
self.state,
|
||||||
self.trace_id,
|
self.trace_id,
|
||||||
self.plan_kind,
|
self.plan_kind,
|
||||||
plan,
|
plan,
|
||||||
watchdog_report_context,
|
watchdog_report_context,
|
||||||
move || async move {
|
move || async move {
|
||||||
execute_execution_runtime_stream(
|
execute_execution_runtime_stream_with_retry_scope(
|
||||||
&execution_state,
|
&execution_state,
|
||||||
execution_plan,
|
execution_plan,
|
||||||
execution_trace_id.as_str(),
|
execution_trace_id.as_str(),
|
||||||
@@ -971,10 +1033,18 @@ where
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(response) = response.as_mut() {
|
match &mut execution {
|
||||||
attach_redaction_execution_candidate(response, plan.candidate_id.as_deref());
|
AiAttemptExecutionOutcome::Responded(response)
|
||||||
|
| AiAttemptExecutionOutcome::Retry {
|
||||||
|
fallback_response: Some(response),
|
||||||
|
..
|
||||||
|
} => attach_redaction_execution_candidate(response, plan.candidate_id.as_deref()),
|
||||||
|
AiAttemptExecutionOutcome::Retry {
|
||||||
|
fallback_response: None,
|
||||||
|
..
|
||||||
|
} => {}
|
||||||
}
|
}
|
||||||
Ok(response)
|
Ok(execution)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_unused_attempts(&self, attempts: Vec<T>) -> Result<(), Self::Error> {
|
async fn mark_unused_attempts(&self, attempts: Vec<T>) -> Result<(), Self::Error> {
|
||||||
@@ -1234,9 +1304,11 @@ async fn execute_stream_candidate_with_watchdog<Fut>(
|
|||||||
plan: &aether_contracts::ExecutionPlan,
|
plan: &aether_contracts::ExecutionPlan,
|
||||||
report_context: Option<&serde_json::Value>,
|
report_context: Option<&serde_json::Value>,
|
||||||
execute: impl FnOnce() -> Fut,
|
execute: impl FnOnce() -> Fut,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError>
|
) -> Result<AiAttemptExecutionOutcome<Response<Body>>, GatewayError>
|
||||||
where
|
where
|
||||||
Fut: std::future::Future<Output = Result<Option<Response<Body>>, GatewayError>> + Send,
|
Fut: std::future::Future<
|
||||||
|
Output = Result<AiAttemptExecutionOutcome<Response<Body>>, GatewayError>,
|
||||||
|
> + Send,
|
||||||
{
|
{
|
||||||
let timeout_duration = resolve_stream_candidate_watchdog_timeout(plan, report_context);
|
let timeout_duration = resolve_stream_candidate_watchdog_timeout(plan, report_context);
|
||||||
let candidate_started_unix_ms = current_unix_ms();
|
let candidate_started_unix_ms = current_unix_ms();
|
||||||
@@ -1252,7 +1324,9 @@ where
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
log_stream_candidate_admission_timeout(trace_id, plan_kind, plan, report_context, &err);
|
log_stream_candidate_admission_timeout(trace_id, plan_kind, plan, report_context, &err);
|
||||||
return Ok(None);
|
return Ok(AiAttemptExecutionOutcome::retry(
|
||||||
|
AiAttemptRetryScope::Candidate,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
};
|
};
|
||||||
@@ -1300,7 +1374,9 @@ where
|
|||||||
timeout_ms,
|
timeout_ms,
|
||||||
"gateway local stream candidate watchdog timed out"
|
"gateway local stream candidate watchdog timed out"
|
||||||
);
|
);
|
||||||
Ok(None)
|
Ok(AiAttemptExecutionOutcome::retry(
|
||||||
|
AiAttemptRetryScope::Candidate,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
observe_gateway_stage_ms(
|
observe_gateway_stage_ms(
|
||||||
@@ -1308,7 +1384,21 @@ where
|
|||||||
watchdog_started_at.elapsed().as_millis() as u64,
|
watchdog_started_at.elapsed().as_millis() as u64,
|
||||||
);
|
);
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(response) => Ok(maybe_hold_upstream_execution_permit(response, permit_hold)),
|
Ok(AiAttemptExecutionOutcome::Responded(response)) => {
|
||||||
|
let response = maybe_hold_upstream_execution_permit(Some(response), permit_hold)
|
||||||
|
.expect("responded stream attempt must retain its response");
|
||||||
|
Ok(AiAttemptExecutionOutcome::Responded(response))
|
||||||
|
}
|
||||||
|
Ok(AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope,
|
||||||
|
fallback_response,
|
||||||
|
}) => {
|
||||||
|
drop(permit_hold);
|
||||||
|
Ok(AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope,
|
||||||
|
fallback_response,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(err) if is_candidate_level_admission_timeout(&err) => {
|
Err(err) if is_candidate_level_admission_timeout(&err) => {
|
||||||
drop(permit_hold);
|
drop(permit_hold);
|
||||||
if should_record_candidate_admission_timeout(&err) {
|
if should_record_candidate_admission_timeout(&err) {
|
||||||
@@ -1322,7 +1412,9 @@ where
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
log_stream_candidate_admission_timeout(trace_id, plan_kind, plan, report_context, &err);
|
log_stream_candidate_admission_timeout(trace_id, plan_kind, plan, report_context, &err);
|
||||||
Ok(None)
|
Ok(AiAttemptExecutionOutcome::retry(
|
||||||
|
AiAttemptRetryScope::Candidate,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
drop(permit_hold);
|
drop(permit_hold);
|
||||||
@@ -1605,6 +1697,14 @@ mod tests {
|
|||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, _key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, _endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, _provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, _provider_id: &str) -> Result<(), GatewayError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1638,6 +1738,7 @@ mod tests {
|
|||||||
struct TransferTestPort<'a> {
|
struct TransferTestPort<'a> {
|
||||||
state: &'a AppState,
|
state: &'a AppState,
|
||||||
tracker: ProviderTransferTracker,
|
tracker: ProviderTransferTracker,
|
||||||
|
retry_scope: AiAttemptRetryScope,
|
||||||
executed: StdMutex<Vec<&'static str>>,
|
executed: StdMutex<Vec<&'static str>>,
|
||||||
unused: StdMutex<Vec<&'static str>>,
|
unused: StdMutex<Vec<&'static str>>,
|
||||||
}
|
}
|
||||||
@@ -1651,6 +1752,17 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
state,
|
state,
|
||||||
tracker,
|
tracker,
|
||||||
|
retry_scope: AiAttemptRetryScope::Candidate,
|
||||||
|
executed: StdMutex::new(Vec::new()),
|
||||||
|
unused: StdMutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_retry_scope(state: &'a AppState, retry_scope: AiAttemptRetryScope) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
tracker: ProviderTransferTracker::default(),
|
||||||
|
retry_scope,
|
||||||
executed: StdMutex::new(Vec::new()),
|
executed: StdMutex::new(Vec::new()),
|
||||||
unused: StdMutex::new(Vec::new()),
|
unused: StdMutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
@@ -1702,9 +1814,13 @@ mod tests {
|
|||||||
async fn execute_attempt(
|
async fn execute_attempt(
|
||||||
&self,
|
&self,
|
||||||
attempt: &TransferTestAttempt,
|
attempt: &TransferTestAttempt,
|
||||||
) -> Result<Option<Self::Response>, Self::Error> {
|
) -> Result<AiAttemptExecutionOutcome<Self::Response>, Self::Error> {
|
||||||
self.executed.lock().unwrap().push(attempt.label);
|
self.executed.lock().unwrap().push(attempt.label);
|
||||||
Ok((attempt.plan.provider_id == "provider-b").then(|| Response::new(Body::from("ok"))))
|
Ok(if attempt.plan.provider_id == "provider-b" {
|
||||||
|
AiAttemptExecutionOutcome::Responded(Response::new(Body::from("ok")))
|
||||||
|
} else {
|
||||||
|
AiAttemptExecutionOutcome::retry(self.retry_scope)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_unused_attempts(
|
async fn mark_unused_attempts(
|
||||||
@@ -1751,6 +1867,18 @@ mod tests {
|
|||||||
Ok(self.attempts.drain(..).collect())
|
Ok(self.attempts.drain(..).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.attempts
|
||||||
|
.retain(|attempt| attempt.plan.key_id != key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.attempts
|
||||||
|
.retain(|attempt| attempt.plan.endpoint_id != endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.skipped_providers.push(provider_id.to_string());
|
self.skipped_providers.push(provider_id.to_string());
|
||||||
self.attempts
|
self.attempts
|
||||||
@@ -1872,6 +2000,36 @@ mod tests {
|
|||||||
assert_eq!(source.skipped_providers, ["provider-a"]);
|
assert_eq!(source.skipped_providers, ["provider-a"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dynamic_loop_applies_provider_scoped_retry_to_candidate_source() {
|
||||||
|
let state = AppState::new().expect("state should build");
|
||||||
|
let port = TransferTestPort::with_retry_scope(&state, AiAttemptRetryScope::Provider);
|
||||||
|
let mut source = TransferTestAttemptSource {
|
||||||
|
attempts: transfer_test_attempts().into(),
|
||||||
|
skipped_providers: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = run_dynamic_attempt_loop(
|
||||||
|
&port,
|
||||||
|
&mut source,
|
||||||
|
"trace-provider-scope-test",
|
||||||
|
"provider_scope_test",
|
||||||
|
Duration::from_secs(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("dynamic attempt loop should succeed");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
LocalExecutionRequestOutcome::Responded(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
port.executed.lock().unwrap().as_slice(),
|
||||||
|
["a-key1-retry0", "b-key1-retry0"]
|
||||||
|
);
|
||||||
|
assert_eq!(source.skipped_providers, ["provider-a"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transfer_timeout_is_checked_at_candidate_boundary_and_zero_disables_limits() {
|
fn transfer_timeout_is_checked_at_candidate_boundary_and_zero_disables_limits() {
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
@@ -2171,14 +2329,24 @@ mod tests {
|
|||||||
"claude_cli_stream",
|
"claude_cli_stream",
|
||||||
&plan,
|
&plan,
|
||||||
Some(&report_context),
|
Some(&report_context),
|
||||||
|| std::future::pending::<Result<Option<Response<Body>>, GatewayError>>(),
|
|| {
|
||||||
|
std::future::pending::<
|
||||||
|
Result<AiAttemptExecutionOutcome<Response<Body>>, GatewayError>,
|
||||||
|
>()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||||
let result = task.await.expect("watchdog task should join");
|
let result = task.await.expect("watchdog task should join");
|
||||||
assert!(matches!(result, Ok(None)));
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Ok(AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope: AiAttemptRetryScope::Candidate,
|
||||||
|
fallback_response: None,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
let records = writer.records.lock().await;
|
let records = writer.records.lock().await;
|
||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
@@ -2226,7 +2394,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(matches!(result, Ok(None)));
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Ok(AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope: AiAttemptRetryScope::Candidate,
|
||||||
|
fallback_response: None,
|
||||||
|
})
|
||||||
|
));
|
||||||
let records = writer.records.lock().await;
|
let records = writer.records.lock().await;
|
||||||
assert_eq!(records.len(), 1);
|
assert_eq!(records.len(), 1);
|
||||||
let record = &records[0];
|
let record = &records[0];
|
||||||
@@ -2268,7 +2442,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(matches!(result, Ok(None)));
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Ok(AiAttemptExecutionOutcome::Retry {
|
||||||
|
scope: AiAttemptRetryScope::Candidate,
|
||||||
|
fallback_response: None,
|
||||||
|
})
|
||||||
|
));
|
||||||
assert!(writer.records.lock().await.is_empty());
|
assert!(writer.records.lock().await.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ pub(crate) use orchestration::*;
|
|||||||
pub(crate) use outcome::{
|
pub(crate) use outcome::{
|
||||||
beautify_local_execution_client_error_message, build_fast_local_execution_exhaustion,
|
beautify_local_execution_client_error_message, build_fast_local_execution_exhaustion,
|
||||||
build_fast_local_execution_runtime_miss_context, build_local_execution_exhaustion,
|
build_fast_local_execution_runtime_miss_context, build_local_execution_exhaustion,
|
||||||
build_local_execution_runtime_miss_context, record_failed_usage_for_exhausted_request,
|
build_local_execution_runtime_miss_context, is_deferred_upstream_response,
|
||||||
|
mark_deferred_upstream_response, record_failed_usage_for_exhausted_request,
|
||||||
record_failed_usage_for_runtime_miss_request, LocalExecutionExhaustion,
|
record_failed_usage_for_runtime_miss_request, LocalExecutionExhaustion,
|
||||||
LocalExecutionRequestOutcome, LocalExecutionRuntimeMissContext,
|
LocalExecutionRequestOutcome, LocalExecutionRuntimeMissContext,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1091,7 +1091,7 @@ fn standard_text_sync_heartbeat_error_kind(status_code: u16) -> LocalCoreSyncErr
|
|||||||
401 => LocalCoreSyncErrorKind::Authentication,
|
401 => LocalCoreSyncErrorKind::Authentication,
|
||||||
403 => LocalCoreSyncErrorKind::PermissionDenied,
|
403 => LocalCoreSyncErrorKind::PermissionDenied,
|
||||||
404 => LocalCoreSyncErrorKind::NotFound,
|
404 => LocalCoreSyncErrorKind::NotFound,
|
||||||
413 => LocalCoreSyncErrorKind::ContextLengthExceeded,
|
413 => LocalCoreSyncErrorKind::RequestTooLarge,
|
||||||
429 => LocalCoreSyncErrorKind::RateLimit,
|
429 => LocalCoreSyncErrorKind::RateLimit,
|
||||||
503 => LocalCoreSyncErrorKind::Overloaded,
|
503 => LocalCoreSyncErrorKind::Overloaded,
|
||||||
_ => LocalCoreSyncErrorKind::ServerError,
|
_ => LocalCoreSyncErrorKind::ServerError,
|
||||||
@@ -1586,6 +1586,18 @@ mod tests {
|
|||||||
Ok(self.attempts.drain(..).collect())
|
Ok(self.attempts.drain(..).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn skip_credential(&mut self, key_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.attempts
|
||||||
|
.retain(|attempt| attempt.plan.key_id != key_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn skip_endpoint(&mut self, endpoint_id: &str) -> Result<(), GatewayError> {
|
||||||
|
self.attempts
|
||||||
|
.retain(|attempt| attempt.plan.endpoint_id != endpoint_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
async fn skip_provider(&mut self, provider_id: &str) -> Result<(), GatewayError> {
|
||||||
self.attempts
|
self.attempts
|
||||||
.retain(|attempt| attempt.plan.provider_id != provider_id);
|
.retain(|attempt| attempt.plan.provider_id != provider_id);
|
||||||
@@ -1813,7 +1825,6 @@ mod tests {
|
|||||||
test_openai_image_heartbeat_attempt(0, "endpoint-retry", "candidate-retry"),
|
test_openai_image_heartbeat_attempt(0, "endpoint-retry", "candidate-retry"),
|
||||||
test_openai_image_heartbeat_attempt(1, "endpoint-success", "candidate-success"),
|
test_openai_image_heartbeat_attempt(1, "endpoint-success", "candidate-success"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let outcome = execute_openai_image_sync_heartbeat_attempts(
|
let outcome = execute_openai_image_sync_heartbeat_attempts(
|
||||||
state,
|
state,
|
||||||
"/v1/images/generations".to_string(),
|
"/v1/images/generations".to_string(),
|
||||||
@@ -2104,7 +2115,6 @@ mod tests {
|
|||||||
"openai:responses:compact",
|
"openai:responses:compact",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
let (parts, _) = http::Request::builder()
|
let (parts, _) = http::Request::builder()
|
||||||
.method(http::Method::POST)
|
.method(http::Method::POST)
|
||||||
.uri("/v1/responses")
|
.uri("/v1/responses")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use base64::Engine as _;
|
|||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::ai_serving::{build_core_error_body_for_client_format, LocalCoreSyncErrorKind};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS, LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER,
|
EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS, LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER,
|
||||||
};
|
};
|
||||||
@@ -32,6 +33,9 @@ pub(crate) enum LocalExecutionRequestOutcome {
|
|||||||
NoPath,
|
NoPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct DeferredUpstreamResponse;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct LocalExecutionExhaustion {
|
pub(crate) struct LocalExecutionExhaustion {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -70,6 +74,18 @@ impl LocalExecutionRequestOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark_deferred_upstream_response(mut response: Response<Body>) -> Response<Body> {
|
||||||
|
response.extensions_mut().insert(DeferredUpstreamResponse);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_deferred_upstream_response(response: &Response<Body>) -> bool {
|
||||||
|
response
|
||||||
|
.extensions()
|
||||||
|
.get::<DeferredUpstreamResponse>()
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
impl LocalExecutionRuntimeMissContext {
|
impl LocalExecutionRuntimeMissContext {
|
||||||
pub(crate) fn persisted_candidate_count(&self) -> usize {
|
pub(crate) fn persisted_candidate_count(&self) -> usize {
|
||||||
self.candidate_contexts.len()
|
self.candidate_contexts.len()
|
||||||
@@ -316,12 +332,12 @@ pub(crate) async fn record_failed_usage_for_exhausted_request(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
data.client_response_headers = Some(Value::Object(client_headers));
|
data.client_response_headers = Some(Value::Object(client_headers));
|
||||||
data.client_response_body = Some(json!({
|
let client_message =
|
||||||
"error": {
|
beautify_local_execution_client_error_message(local_execution_runtime_miss_detail);
|
||||||
"type": "http_error",
|
data.client_response_body = Some(runtime_miss_client_error_body(
|
||||||
"message": beautify_local_execution_client_error_message(local_execution_runtime_miss_detail),
|
data.api_format.as_deref(),
|
||||||
}
|
&client_message,
|
||||||
}));
|
));
|
||||||
|
|
||||||
let mut request_metadata = match data.request_metadata.take() {
|
let mut request_metadata = match data.request_metadata.take() {
|
||||||
Some(Value::Object(object)) => object,
|
Some(Value::Object(object)) => object,
|
||||||
@@ -392,12 +408,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
|
|||||||
let status_code = http::StatusCode::SERVICE_UNAVAILABLE.as_u16();
|
let status_code = http::StatusCode::SERVICE_UNAVAILABLE.as_u16();
|
||||||
let client_message =
|
let client_message =
|
||||||
beautify_local_execution_client_error_message(local_execution_runtime_miss_detail);
|
beautify_local_execution_client_error_message(local_execution_runtime_miss_detail);
|
||||||
let client_body = json!({
|
let client_body = runtime_miss_client_error_body(api_format.as_deref(), &client_message);
|
||||||
"error": {
|
|
||||||
"type": "http_error",
|
|
||||||
"message": client_message,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut client_headers = Map::from_iter([(
|
let mut client_headers = Map::from_iter([(
|
||||||
"content-type".to_string(),
|
"content-type".to_string(),
|
||||||
Value::String("application/json".to_string()),
|
Value::String("application/json".to_string()),
|
||||||
@@ -654,6 +665,30 @@ fn json_header_map() -> Value {
|
|||||||
)]))
|
)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_miss_client_error_body(api_format: Option<&str>, message: &str) -> Value {
|
||||||
|
let fallback = json!({
|
||||||
|
"error": {
|
||||||
|
"type": "http_error",
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let is_claude = api_format.is_some_and(|format| {
|
||||||
|
crate::ai_serving::normalize_api_format_alias(format)
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
});
|
||||||
|
if !is_claude {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
build_core_error_body_for_client_format(
|
||||||
|
"claude:messages",
|
||||||
|
message,
|
||||||
|
None,
|
||||||
|
LocalCoreSyncErrorKind::Overloaded,
|
||||||
|
)
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_miss_original_headers_json(headers: &HeaderMap) -> Value {
|
fn runtime_miss_original_headers_json(headers: &HeaderMap) -> Value {
|
||||||
let mut headers = crate::headers::collect_control_headers(headers);
|
let mut headers = crate::headers::collect_control_headers(headers);
|
||||||
for (name, value) in headers.iter_mut() {
|
for (name, value) in headers.iter_mut() {
|
||||||
@@ -1135,7 +1170,7 @@ fn trimmed_non_empty(value: Option<&str>) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
|
apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message,
|
||||||
request_candidate_represents_provider_execution,
|
request_candidate_represents_provider_execution, runtime_miss_client_error_body,
|
||||||
select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext,
|
select_last_runtime_miss_executed_candidate, LocalExecutionRuntimeMissContext,
|
||||||
RuntimeMissCandidateContext,
|
RuntimeMissCandidateContext,
|
||||||
};
|
};
|
||||||
@@ -1169,6 +1204,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_miss_usage_body_matches_claude_client_envelope() {
|
||||||
|
let claude = runtime_miss_client_error_body(Some("claude:messages"), "busy");
|
||||||
|
assert_eq!(claude["type"], "error");
|
||||||
|
assert_eq!(claude["error"]["type"], "overloaded_error");
|
||||||
|
|
||||||
|
let openai = runtime_miss_client_error_body(Some("openai:chat"), "busy");
|
||||||
|
assert_eq!(openai["error"]["type"], "http_error");
|
||||||
|
assert!(openai.get("type").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_miss_routing_moves_to_typed_usage_fields_and_keeps_metadata_lightweight() {
|
fn runtime_miss_routing_moves_to_typed_usage_fields_and_keeps_metadata_lightweight() {
|
||||||
let mut data = UsageEventData::default();
|
let mut data = UsageEventData::default();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use aether_ai_serving::{
|
use aether_ai_serving::{
|
||||||
run_ai_stream_execution_path, AiPlanFallbackReason, AiServingExecutionOutcome,
|
run_ai_stream_execution_path, AiPlanFallbackReason, AiServingExecutionOutcome,
|
||||||
AiStreamExecutionPathPort, AiStreamExecutionStep,
|
AiStreamExecutionPathPort, AiStreamExecutionStep, OriginalRequestPayload,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
@@ -59,6 +59,21 @@ pub(crate) async fn maybe_execute_via_stream_decision_path(
|
|||||||
);
|
);
|
||||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
let mut planning_parts = parts.clone();
|
||||||
|
if crate::ai_serving::is_json_request(&planning_parts.headers) {
|
||||||
|
if let Ok(decoded_body) = crate::ai_serving::decoded_request_body_bytes(
|
||||||
|
&planning_parts.headers,
|
||||||
|
body_bytes.as_ref(),
|
||||||
|
) {
|
||||||
|
planning_parts
|
||||||
|
.extensions
|
||||||
|
.insert(OriginalRequestPayload::from_parsed_json(
|
||||||
|
body_json.clone(),
|
||||||
|
decoded_body.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parts = &planning_parts;
|
||||||
observe_gateway_stage_ms(
|
observe_gateway_stage_ms(
|
||||||
"frontdoor_stream_parse",
|
"frontdoor_stream_parse",
|
||||||
parse_started_at.elapsed().as_millis() as u64,
|
parse_started_at.elapsed().as_millis() as u64,
|
||||||
@@ -422,7 +437,11 @@ fn to_ai_serving_outcome(
|
|||||||
) -> AiServingExecutionOutcome<Response<Body>, super::LocalExecutionExhaustion> {
|
) -> AiServingExecutionOutcome<Response<Body>, super::LocalExecutionExhaustion> {
|
||||||
match outcome {
|
match outcome {
|
||||||
LocalExecutionRequestOutcome::Responded(response) => {
|
LocalExecutionRequestOutcome::Responded(response) => {
|
||||||
AiServingExecutionOutcome::Responded(response)
|
if super::is_deferred_upstream_response(&response) {
|
||||||
|
AiServingExecutionOutcome::Deferred(response)
|
||||||
|
} else {
|
||||||
|
AiServingExecutionOutcome::Responded(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
AiServingExecutionOutcome::Exhausted(outcome)
|
AiServingExecutionOutcome::Exhausted(outcome)
|
||||||
@@ -438,6 +457,9 @@ fn from_ai_serving_outcome(
|
|||||||
AiServingExecutionOutcome::Responded(response) => {
|
AiServingExecutionOutcome::Responded(response) => {
|
||||||
LocalExecutionRequestOutcome::Responded(response)
|
LocalExecutionRequestOutcome::Responded(response)
|
||||||
}
|
}
|
||||||
|
AiServingExecutionOutcome::Deferred(response) => {
|
||||||
|
LocalExecutionRequestOutcome::Responded(response)
|
||||||
|
}
|
||||||
AiServingExecutionOutcome::Exhausted(outcome) => {
|
AiServingExecutionOutcome::Exhausted(outcome) => {
|
||||||
LocalExecutionRequestOutcome::Exhausted(outcome)
|
LocalExecutionRequestOutcome::Exhausted(outcome)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use aether_ai_serving::{
|
use aether_ai_serving::{
|
||||||
run_ai_sync_execution_path, AiPlanFallbackReason, AiServingExecutionOutcome,
|
run_ai_sync_execution_path, AiPlanFallbackReason, AiServingExecutionOutcome,
|
||||||
AiSyncExecutionPathPort, AiSyncExecutionStep,
|
AiSyncExecutionPathPort, AiSyncExecutionStep, OriginalRequestPayload,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
@@ -51,6 +51,22 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
|||||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut planning_parts = parts.clone();
|
||||||
|
if crate::ai_serving::is_json_request(&planning_parts.headers) {
|
||||||
|
if let Ok(decoded_body) = crate::ai_serving::decoded_request_body_bytes(
|
||||||
|
&planning_parts.headers,
|
||||||
|
body_bytes.as_ref(),
|
||||||
|
) {
|
||||||
|
planning_parts
|
||||||
|
.extensions
|
||||||
|
.insert(OriginalRequestPayload::from_parsed_json(
|
||||||
|
body_json.clone(),
|
||||||
|
decoded_body.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parts = &planning_parts;
|
||||||
|
|
||||||
if let Some(stream_plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) {
|
if let Some(stream_plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) {
|
||||||
if is_matching_stream_request(stream_plan_kind, parts, &body_json, body_base64.as_deref()) {
|
if is_matching_stream_request(stream_plan_kind, parts, &body_json, body_base64.as_deref()) {
|
||||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
@@ -256,7 +272,11 @@ fn to_ai_serving_outcome(
|
|||||||
) -> AiServingExecutionOutcome<Response<Body>, super::LocalExecutionExhaustion> {
|
) -> AiServingExecutionOutcome<Response<Body>, super::LocalExecutionExhaustion> {
|
||||||
match outcome {
|
match outcome {
|
||||||
LocalExecutionRequestOutcome::Responded(response) => {
|
LocalExecutionRequestOutcome::Responded(response) => {
|
||||||
AiServingExecutionOutcome::Responded(response)
|
if super::is_deferred_upstream_response(&response) {
|
||||||
|
AiServingExecutionOutcome::Deferred(response)
|
||||||
|
} else {
|
||||||
|
AiServingExecutionOutcome::Responded(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
AiServingExecutionOutcome::Exhausted(outcome)
|
AiServingExecutionOutcome::Exhausted(outcome)
|
||||||
@@ -272,6 +292,9 @@ fn from_ai_serving_outcome(
|
|||||||
AiServingExecutionOutcome::Responded(response) => {
|
AiServingExecutionOutcome::Responded(response) => {
|
||||||
LocalExecutionRequestOutcome::Responded(response)
|
LocalExecutionRequestOutcome::Responded(response)
|
||||||
}
|
}
|
||||||
|
AiServingExecutionOutcome::Deferred(response) => {
|
||||||
|
LocalExecutionRequestOutcome::Responded(response)
|
||||||
|
}
|
||||||
AiServingExecutionOutcome::Exhausted(outcome) => {
|
AiServingExecutionOutcome::Exhausted(outcome) => {
|
||||||
LocalExecutionRequestOutcome::Exhausted(outcome)
|
LocalExecutionRequestOutcome::Exhausted(outcome)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ mod tests {
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("openai".to_string()),
|
route_family: Some("openai".to_string()),
|
||||||
route_kind: Some("chat".to_string()),
|
route_kind: Some("chat".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: None,
|
auth_endpoint_signature: None,
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
|||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.clone(),
|
key_id: key_id.clone(),
|
||||||
expected_encrypted_auth_config: state_data.expected_encrypted_auth_config,
|
expected_encrypted_auth_config: state_data.expected_encrypted_auth_config,
|
||||||
|
expected_credential: None,
|
||||||
encrypted_auth_config: persisted_encrypted_auth_config.clone(),
|
encrypted_auth_config: persisted_encrypted_auth_config.clone(),
|
||||||
encrypted_api_key_update: Some(encrypted_api_key),
|
encrypted_api_key_update: Some(encrypted_api_key),
|
||||||
expires_at_unix_secs_update: Some(expires_at),
|
expires_at_unix_secs_update: Some(expires_at),
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ pub(crate) async fn persist_fenced_provider_quota_refresh_state(
|
|||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
expected_encrypted_auth_config: Some(expected_encrypted_auth_config.to_string()),
|
expected_encrypted_auth_config: Some(expected_encrypted_auth_config.to_string()),
|
||||||
|
expected_credential: None,
|
||||||
encrypted_auth_config: expected_encrypted_auth_config.to_string(),
|
encrypted_auth_config: expected_encrypted_auth_config.to_string(),
|
||||||
encrypted_api_key_update: None,
|
encrypted_api_key_update: None,
|
||||||
expires_at_unix_secs_update: None,
|
expires_at_unix_secs_update: None,
|
||||||
|
|||||||
@@ -100,14 +100,6 @@ fn select_provider_oauth_runtime_endpoint(
|
|||||||
.api_format
|
.api_format
|
||||||
.trim()
|
.trim()
|
||||||
.eq_ignore_ascii_case("gemini:generate_content")
|
.eq_ignore_ascii_case("gemini:generate_content")
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
matching_endpoint(endpoints, include_inactive, |endpoint| {
|
|
||||||
endpoint
|
|
||||||
.api_format
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("claude:messages")
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
_ => matching_endpoint(endpoints, include_inactive, |_| true),
|
_ => matching_endpoint(endpoints, include_inactive, |_| true),
|
||||||
}
|
}
|
||||||
@@ -255,3 +247,44 @@ pub(crate) fn spawn_provider_oauth_account_state_refresh_after_update(
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
provider_oauth_maintenance_endpoint_for_provider,
|
||||||
|
provider_oauth_runtime_endpoint_for_provider,
|
||||||
|
};
|
||||||
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
|
||||||
|
|
||||||
|
fn endpoint(id: &str, api_format: &str, is_active: bool) -> StoredProviderCatalogEndpoint {
|
||||||
|
StoredProviderCatalogEndpoint::new(
|
||||||
|
id.to_string(),
|
||||||
|
"provider-1".to_string(),
|
||||||
|
api_format.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
is_active,
|
||||||
|
)
|
||||||
|
.expect("endpoint should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertex_oauth_runtime_never_falls_back_to_retired_claude_endpoint() {
|
||||||
|
let endpoints = vec![endpoint("claude", "claude:messages", true)];
|
||||||
|
|
||||||
|
assert!(provider_oauth_runtime_endpoint_for_provider("vertex_ai", &endpoints).is_none());
|
||||||
|
assert!(
|
||||||
|
provider_oauth_maintenance_endpoint_for_provider("vertex_ai", &endpoints).is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let endpoints = vec![
|
||||||
|
endpoint("claude", "claude:messages", true),
|
||||||
|
endpoint("gemini", "gemini:generate_content", true),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
provider_oauth_runtime_endpoint_for_provider("vertex_ai", &endpoints)
|
||||||
|
.map(|endpoint| endpoint.id),
|
||||||
|
Some("gemini".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2668,6 +2668,7 @@ async fn provider_query_execute_antigravity_test_candidate(
|
|||||||
upstream_is_stream: false,
|
upstream_is_stream: false,
|
||||||
request_query: parts.uri.query(),
|
request_query: parts.uri.query(),
|
||||||
kiro_api_region: None,
|
kiro_api_region: None,
|
||||||
|
api_operation: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let Some(request_url) = request_url else {
|
let Some(request_url) = request_url else {
|
||||||
@@ -3364,6 +3365,7 @@ async fn provider_query_execute_standard_test_candidate(
|
|||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
request_query: parts.uri.query(),
|
request_query: parts.uri.query(),
|
||||||
kiro_api_region: None,
|
kiro_api_region: None,
|
||||||
|
api_operation: None,
|
||||||
},
|
},
|
||||||
Some(&provider_request_body),
|
Some(&provider_request_body),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -195,11 +195,7 @@ pub(crate) fn validate_vertex_api_formats(
|
|||||||
|
|
||||||
let allowed = match auth_type {
|
let allowed = match auth_type {
|
||||||
"api_key" => &["gemini:generate_content", "gemini:embedding"][..],
|
"api_key" => &["gemini:generate_content", "gemini:embedding"][..],
|
||||||
"service_account" | "vertex_ai" => &[
|
"service_account" | "vertex_ai" => &["gemini:generate_content", "gemini:embedding"][..],
|
||||||
"claude:messages",
|
|
||||||
"gemini:generate_content",
|
|
||||||
"gemini:embedding",
|
|
||||||
][..],
|
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let invalid = api_formats
|
let invalid = api_formats
|
||||||
@@ -410,20 +406,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_vertex_api_formats_uses_canonical_message_formats() {
|
fn validate_vertex_api_formats_rejects_unimplemented_anthropic_transport() {
|
||||||
assert!(validate_vertex_api_formats(
|
assert!(validate_vertex_api_formats(
|
||||||
"vertex_ai",
|
"vertex_ai",
|
||||||
"service_account",
|
"service_account",
|
||||||
&[
|
&["claude:messages".to_string()],
|
||||||
"claude:messages".to_string(),
|
|
||||||
"gemini:generate_content".to_string()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.is_ok());
|
|
||||||
assert!(validate_vertex_api_formats(
|
|
||||||
"vertex_ai",
|
|
||||||
"service_account",
|
|
||||||
&["claude:chat".to_string()],
|
|
||||||
)
|
)
|
||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
@@ -443,7 +430,6 @@ mod tests {
|
|||||||
"vertex_ai",
|
"vertex_ai",
|
||||||
"service_account",
|
"service_account",
|
||||||
&[
|
&[
|
||||||
"claude:messages".to_string(),
|
|
||||||
"gemini:generate_content".to_string(),
|
"gemini:generate_content".to_string(),
|
||||||
"gemini:embedding".to_string()
|
"gemini:embedding".to_string()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ pub(crate) async fn build_admin_create_provider_record(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
|
let config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(config.as_ref())
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())?;
|
||||||
|
|
||||||
let now_unix_secs = SystemTime::now()
|
let now_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|||||||
@@ -312,6 +312,10 @@ pub(crate) async fn build_admin_update_provider_record(
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated.config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
|
updated.config = (!config_map.is_empty()).then_some(serde_json::Value::Object(config_map));
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||||||
|
updated.config.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())?;
|
||||||
updated.updated_at_unix_secs = SystemTime::now()
|
updated.updated_at_unix_secs = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ impl<'a> AdminAppState<'a> {
|
|||||||
admin_endpoint_signature_parts(&payload.api_format)
|
admin_endpoint_signature_parts(&payload.api_format)
|
||||||
.ok_or_else(|| format!("无效的 api_format: {}", payload.api_format))?;
|
.ok_or_else(|| format!("无效的 api_format: {}", payload.api_format))?;
|
||||||
validate_admin_endpoint_stream_policy(normalized_api_format, payload.config.as_ref())?;
|
validate_admin_endpoint_stream_policy(normalized_api_format, payload.config.as_ref())?;
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||||||
|
payload.config.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())?;
|
||||||
let base_url = normalize_admin_base_url(&payload.base_url)?;
|
let base_url = normalize_admin_base_url(&payload.base_url)?;
|
||||||
|
|
||||||
let existing_endpoints = self
|
let existing_endpoints = self
|
||||||
@@ -369,6 +373,10 @@ impl<'a> AdminAppState<'a> {
|
|||||||
existing_endpoint.api_format.as_str(),
|
existing_endpoint.api_format.as_str(),
|
||||||
updated.config.as_ref(),
|
updated.config.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||||||
|
updated.config.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider_type == "codex"
|
if provider_type == "codex"
|
||||||
|
|||||||
@@ -192,6 +192,15 @@ fn normalize_import_endpoint_format(value: &str) -> Result<String, String> {
|
|||||||
.ok_or_else(|| format!("无效的 api_format: {value}"))
|
.ok_or_else(|| format!("无效的 api_format: {value}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fixed_provider_import_endpoint_supported(provider_type: &str, api_format: &str) -> bool {
|
||||||
|
crate::provider_transport::provider_types::fixed_provider_template(provider_type).is_none()
|
||||||
|
|| crate::provider_transport::provider_types::fixed_provider_endpoint_template_by_api_format(
|
||||||
|
provider_type,
|
||||||
|
api_format,
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_import_key_formats(
|
fn normalize_import_key_formats(
|
||||||
item: &ImportedProviderKey,
|
item: &ImportedProviderKey,
|
||||||
provider_endpoint_formats: &BTreeSet<String>,
|
provider_endpoint_formats: &BTreeSet<String>,
|
||||||
@@ -1419,6 +1428,12 @@ impl<'a> AdminAppState<'a> {
|
|||||||
for imported_provider_item in imported_providers {
|
for imported_provider_item in imported_providers {
|
||||||
let (raw_provider, imported_provider) = imported_provider_item.into_parts();
|
let (raw_provider, imported_provider) = imported_provider_item.into_parts();
|
||||||
let provider_name = invalid!(trim_required(&imported_provider.name, "name"));
|
let provider_name = invalid!(trim_required(&imported_provider.name, "name"));
|
||||||
|
invalid!(
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||||||
|
imported_provider.config.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||||||
|
);
|
||||||
let existing_provider = providers_by_name.get(&provider_name).cloned();
|
let existing_provider = providers_by_name.get(&provider_name).cloned();
|
||||||
|
|
||||||
let provider = if let Some(existing) = existing_provider {
|
let provider = if let Some(existing) = existing_provider {
|
||||||
@@ -1513,6 +1528,42 @@ impl<'a> AdminAppState<'a> {
|
|||||||
let normalized_api_format = invalid!(normalize_import_endpoint_format(
|
let normalized_api_format = invalid!(normalize_import_endpoint_format(
|
||||||
&imported_endpoint.api_format
|
&imported_endpoint.api_format
|
||||||
));
|
));
|
||||||
|
invalid!(
|
||||||
|
crate::provider_transport::validate_anthropic_compatibility_profile_config(
|
||||||
|
imported_endpoint.config.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "无效的 Anthropic compatibility profile".to_string())
|
||||||
|
);
|
||||||
|
if !fixed_provider_import_endpoint_supported(
|
||||||
|
&provider.provider_type,
|
||||||
|
&normalized_api_format,
|
||||||
|
) {
|
||||||
|
let retired = existing_endpoints_by_format.remove(&normalized_api_format);
|
||||||
|
if let Some(mut retired) = retired {
|
||||||
|
if retired.is_active {
|
||||||
|
retired.is_active = false;
|
||||||
|
retired.updated_at_unix_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|duration| duration.as_secs());
|
||||||
|
let Some(_) = self.update_provider_catalog_endpoint(&retired).await?
|
||||||
|
else {
|
||||||
|
return Ok(Err(invalid_request(format!(
|
||||||
|
"停用 Provider '{provider_name}' 的已移除 Endpoint '{normalized_api_format}' 失败"
|
||||||
|
))));
|
||||||
|
};
|
||||||
|
stats.endpoints.updated += 1;
|
||||||
|
} else {
|
||||||
|
stats.endpoints.skipped += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.endpoints.skipped += 1;
|
||||||
|
}
|
||||||
|
stats.errors.push(format!(
|
||||||
|
"固定 Provider '{provider_name}' 不再支持 Endpoint '{normalized_api_format}',已跳过或停用"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let existing_endpoint = existing_endpoints_by_format
|
let existing_endpoint = existing_endpoints_by_format
|
||||||
.get(&normalized_api_format)
|
.get(&normalized_api_format)
|
||||||
.cloned();
|
.cloned();
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use crate::ai_serving::api::{
|
|||||||
};
|
};
|
||||||
use crate::api::response::{
|
use crate::api::response::{
|
||||||
build_client_response, build_client_response_from_parts, build_local_auth_rejection_response,
|
build_client_response, build_client_response_from_parts, build_local_auth_rejection_response,
|
||||||
build_local_http_error_response, build_local_overloaded_response,
|
build_local_http_error_response, build_local_http_error_response_with_request_path,
|
||||||
build_local_user_rpm_limited_response,
|
build_local_overloaded_response, build_local_user_rpm_limited_response,
|
||||||
};
|
};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
CONTROL_CANDIDATE_ID_HEADER, DEPENDENCY_REASON_HEADER, EXECUTION_PATH_CONTROL_EXECUTE_STREAM,
|
CONTROL_CANDIDATE_ID_HEADER, DEPENDENCY_REASON_HEADER, EXECUTION_PATH_CONTROL_EXECUTE_STREAM,
|
||||||
@@ -935,7 +935,13 @@ async fn proxy_request_inner(
|
|||||||
limit,
|
limit,
|
||||||
})) => {
|
})) => {
|
||||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||||
let response = build_local_overloaded_response(&trace_id, None, gate, limit)?;
|
let response = build_local_overloaded_response(
|
||||||
|
&trace_id,
|
||||||
|
None,
|
||||||
|
Some(request.uri().path()),
|
||||||
|
gate,
|
||||||
|
limit,
|
||||||
|
)?;
|
||||||
return Ok(finalize_gateway_response(
|
return Ok(finalize_gateway_response(
|
||||||
&state,
|
&state,
|
||||||
response,
|
response,
|
||||||
@@ -965,7 +971,13 @@ async fn proxy_request_inner(
|
|||||||
aether_runtime_state::RuntimeSemaphoreError::Unavailable { gate, limit, .. },
|
aether_runtime_state::RuntimeSemaphoreError::Unavailable { gate, limit, .. },
|
||||||
)) => {
|
)) => {
|
||||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||||
let response = build_local_overloaded_response(&trace_id, None, gate, limit)?;
|
let response = build_local_overloaded_response(
|
||||||
|
&trace_id,
|
||||||
|
None,
|
||||||
|
Some(request.uri().path()),
|
||||||
|
gate,
|
||||||
|
limit,
|
||||||
|
)?;
|
||||||
return Ok(finalize_gateway_response(
|
return Ok(finalize_gateway_response(
|
||||||
&state,
|
&state,
|
||||||
response,
|
response,
|
||||||
@@ -999,9 +1011,10 @@ async fn proxy_request_inner(
|
|||||||
path = %request.uri().path(),
|
path = %request.uri().path(),
|
||||||
"gateway rejected blacklisted client IP"
|
"gateway rejected blacklisted client IP"
|
||||||
);
|
);
|
||||||
let response = build_local_http_error_response(
|
let response = build_local_http_error_response_with_request_path(
|
||||||
&trace_id,
|
&trace_id,
|
||||||
None,
|
None,
|
||||||
|
Some(request.uri().path()),
|
||||||
http::StatusCode::FORBIDDEN,
|
http::StatusCode::FORBIDDEN,
|
||||||
"当前 IP 已被禁止访问",
|
"当前 IP 已被禁止访问",
|
||||||
)?;
|
)?;
|
||||||
@@ -1057,9 +1070,10 @@ async fn proxy_request_inner(
|
|||||||
loop_guard_header = EXECUTION_RUNTIME_LOOP_GUARD_HEADER,
|
loop_guard_header = EXECUTION_RUNTIME_LOOP_GUARD_HEADER,
|
||||||
"gateway rejected execution runtime request loop into frontdoor"
|
"gateway rejected execution runtime request loop into frontdoor"
|
||||||
);
|
);
|
||||||
let response = build_local_http_error_response(
|
let response = build_local_http_error_response_with_request_path(
|
||||||
&trace_id,
|
&trace_id,
|
||||||
None,
|
None,
|
||||||
|
Some(parts.uri.path()),
|
||||||
http::StatusCode::LOOP_DETECTED,
|
http::StatusCode::LOOP_DETECTED,
|
||||||
LOCAL_EXECUTION_LOOP_DETECTED_DETAIL,
|
LOCAL_EXECUTION_LOOP_DETECTED_DETAIL,
|
||||||
)?;
|
)?;
|
||||||
@@ -1534,9 +1548,10 @@ async fn proxy_request_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if control_decision.is_none() {
|
if control_decision.is_none() {
|
||||||
let response = build_local_http_error_response(
|
let response = build_local_http_error_response_with_request_path(
|
||||||
&trace_id,
|
&trace_id,
|
||||||
None,
|
None,
|
||||||
|
Some(request_context.request_path.as_str()),
|
||||||
http::StatusCode::NOT_FOUND,
|
http::StatusCode::NOT_FOUND,
|
||||||
LOCAL_ROUTE_NOT_FOUND_DETAIL,
|
LOCAL_ROUTE_NOT_FOUND_DETAIL,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::ai_serving::normalize_openai_image_quality;
|
use crate::ai_serving::{
|
||||||
|
build_core_error_body_for_client_format, normalize_openai_image_quality, LocalCoreSyncErrorKind,
|
||||||
|
};
|
||||||
use crate::async_task::CancelVideoTaskError;
|
use crate::async_task::CancelVideoTaskError;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::control::GatewayPublicRequestContext;
|
use crate::control::GatewayPublicRequestContext;
|
||||||
@@ -13,8 +15,6 @@ use axum::response::IntoResponse;
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL: &str = "Invalid token count payload";
|
|
||||||
const CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL: &str = "请求体不能为空";
|
|
||||||
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
|
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
|
||||||
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
|
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
|
||||||
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
|
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
|
||||||
@@ -51,6 +51,10 @@ const OPENAI_RERANK_TOP_N_DETAIL: &str = "Rerank request top_n must be a positiv
|
|||||||
const OPENAI_RERANK_CHAT_PAYLOAD_DETAIL: &str =
|
const OPENAI_RERANK_CHAT_PAYLOAD_DETAIL: &str =
|
||||||
"Rerank request must use query/documents, not chat messages";
|
"Rerank request must use query/documents, not chat messages";
|
||||||
const OPENAI_RERANK_STREAM_UNSUPPORTED_DETAIL: &str = "Rerank requests do not support streaming";
|
const OPENAI_RERANK_STREAM_UNSUPPORTED_DETAIL: &str = "Rerank requests do not support streaming";
|
||||||
|
const CLAUDE_COUNT_TOKENS_BODY_REQUIRED_DETAIL: &str = "Request body is required";
|
||||||
|
const CLAUDE_COUNT_TOKENS_INVALID_JSON_DETAIL: &str = "Invalid JSON body";
|
||||||
|
const CLAUDE_COUNT_TOKENS_MODEL_REQUIRED_DETAIL: &str = "model: Field required";
|
||||||
|
const CLAUDE_COUNT_TOKENS_MESSAGES_REQUIRED_DETAIL: &str = "messages: Field required";
|
||||||
const ANTIGRAVITY_USER_SETTINGS_MISSING_BODY_DETAIL: &str =
|
const ANTIGRAVITY_USER_SETTINGS_MISSING_BODY_DETAIL: &str =
|
||||||
"Antigravity setUserSettings request body is required";
|
"Antigravity setUserSettings request body is required";
|
||||||
const ANTIGRAVITY_USER_SETTINGS_INVALID_JSON_DETAIL: &str =
|
const ANTIGRAVITY_USER_SETTINGS_INVALID_JSON_DETAIL: &str =
|
||||||
@@ -135,7 +139,7 @@ pub(crate) async fn maybe_build_local_ai_public_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(response) =
|
if let Some(response) =
|
||||||
maybe_build_local_claude_count_tokens_response(request_context, request_body)
|
maybe_build_local_claude_count_tokens_validation_response(request_context, request_body)
|
||||||
{
|
{
|
||||||
return Some(response);
|
return Some(response);
|
||||||
}
|
}
|
||||||
@@ -863,7 +867,7 @@ fn maybe_build_local_ai_public_route_guard_response(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_build_local_claude_count_tokens_response(
|
fn maybe_build_local_claude_count_tokens_validation_response(
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Option<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
@@ -876,34 +880,45 @@ fn maybe_build_local_claude_count_tokens_response(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(request_body) = request_body else {
|
let validation = validate_claude_count_tokens_request(request_body);
|
||||||
return Some(build_ai_public_error_response(
|
validation.err().map(build_claude_invalid_request_response)
|
||||||
http::StatusCode::BAD_REQUEST,
|
}
|
||||||
CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL,
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
fn validate_claude_count_tokens_request(request_body: Option<&Bytes>) -> Result<(), &'static str> {
|
||||||
Ok(payload) => payload,
|
let request_body = request_body
|
||||||
Err(_) => {
|
.filter(|body| !body.is_empty())
|
||||||
return Some(build_ai_public_error_response(
|
.ok_or(CLAUDE_COUNT_TOKENS_BODY_REQUIRED_DETAIL)?;
|
||||||
http::StatusCode::BAD_REQUEST,
|
let payload = serde_json::from_slice::<Value>(request_body)
|
||||||
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
|
.map_err(|_| CLAUDE_COUNT_TOKENS_INVALID_JSON_DETAIL)?;
|
||||||
));
|
let object = payload
|
||||||
}
|
.as_object()
|
||||||
};
|
.ok_or(CLAUDE_COUNT_TOKENS_INVALID_JSON_DETAIL)?;
|
||||||
|
|
||||||
let input_tokens = match estimate_claude_count_tokens(&payload) {
|
if object
|
||||||
Ok(tokens) => tokens,
|
.get("model")
|
||||||
Err(_) => {
|
.and_then(Value::as_str)
|
||||||
return Some(build_ai_public_error_response(
|
.map(str::trim)
|
||||||
http::StatusCode::BAD_REQUEST,
|
.filter(|model| !model.is_empty())
|
||||||
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
|
.is_none()
|
||||||
));
|
{
|
||||||
}
|
return Err(CLAUDE_COUNT_TOKENS_MODEL_REQUIRED_DETAIL);
|
||||||
};
|
}
|
||||||
|
if object.get("messages").and_then(Value::as_array).is_none() {
|
||||||
|
return Err(CLAUDE_COUNT_TOKENS_MESSAGES_REQUIRED_DETAIL);
|
||||||
|
}
|
||||||
|
|
||||||
Some(Json(json!({ "input_tokens": input_tokens })).into_response())
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_claude_invalid_request_response(detail: &'static str) -> Response<Body> {
|
||||||
|
let body = build_core_error_body_for_client_format(
|
||||||
|
"claude:messages",
|
||||||
|
detail,
|
||||||
|
None,
|
||||||
|
LocalCoreSyncErrorKind::InvalidRequest,
|
||||||
|
)
|
||||||
|
.expect("Claude core error format should be available");
|
||||||
|
(http::StatusCode::BAD_REQUEST, Json(body)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_build_local_antigravity_v1internal_response(
|
fn maybe_build_local_antigravity_v1internal_response(
|
||||||
@@ -1583,132 +1598,43 @@ fn build_ai_public_error_response(
|
|||||||
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn estimate_claude_count_tokens(payload: &serde_json::Value) -> Result<u64, ()> {
|
|
||||||
let object = payload.as_object().ok_or(())?;
|
|
||||||
let model = object
|
|
||||||
.get("model")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or(())?;
|
|
||||||
if model.trim().is_empty() {
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = object
|
|
||||||
.get("messages")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.ok_or(())?;
|
|
||||||
|
|
||||||
let system_tokens = estimate_claude_system_tokens(object.get("system"))?;
|
|
||||||
let message_tokens = estimate_claude_message_tokens(messages)?;
|
|
||||||
Ok(system_tokens.saturating_add(message_tokens))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimate_claude_system_tokens(system: Option<&serde_json::Value>) -> Result<u64, ()> {
|
|
||||||
let Some(system) = system else {
|
|
||||||
return Ok(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
match system {
|
|
||||||
serde_json::Value::Null => Ok(0),
|
|
||||||
serde_json::Value::String(text) => Ok(estimate_text_tokens(text)),
|
|
||||||
serde_json::Value::Array(blocks) => {
|
|
||||||
let mut total = 0_u64;
|
|
||||||
for block in blocks {
|
|
||||||
let block = block.as_object().ok_or(())?;
|
|
||||||
if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
|
|
||||||
total = total.saturating_add(estimate_text_tokens(text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(_) => Ok(0),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimate_claude_message_tokens(messages: &[serde_json::Value]) -> Result<u64, ()> {
|
|
||||||
let mut total = 0_u64;
|
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
let message = message.as_object().ok_or(())?;
|
|
||||||
let role = message
|
|
||||||
.get("role")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or(())?;
|
|
||||||
if !matches!(role, "user" | "assistant") {
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
|
|
||||||
total = total.saturating_add(4);
|
|
||||||
let content = message.get("content").ok_or(())?;
|
|
||||||
match content {
|
|
||||||
serde_json::Value::String(text) => {
|
|
||||||
total = total.saturating_add(estimate_text_tokens(text));
|
|
||||||
}
|
|
||||||
serde_json::Value::Array(items) => {
|
|
||||||
for item in items {
|
|
||||||
let item = item.as_object().ok_or(())?;
|
|
||||||
if let Some(text) = item.get("text").and_then(serde_json::Value::as_str) {
|
|
||||||
total = total.saturating_add(estimate_text_tokens(text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => return Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimate_text_tokens(text: &str) -> u64 {
|
|
||||||
if text.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let char_count = text.chars().count() as u64;
|
|
||||||
std::cmp::max(1, char_count / 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
estimate_claude_count_tokens, parse_openai_image_validation_input, validate_openai_image_n,
|
parse_openai_image_validation_input, validate_claude_count_tokens_request,
|
||||||
OpenAiImageOperation,
|
validate_openai_image_n, OpenAiImageOperation, CLAUDE_COUNT_TOKENS_BODY_REQUIRED_DETAIL,
|
||||||
|
CLAUDE_COUNT_TOKENS_INVALID_JSON_DETAIL, CLAUDE_COUNT_TOKENS_MESSAGES_REQUIRED_DETAIL,
|
||||||
|
CLAUDE_COUNT_TOKENS_MODEL_REQUIRED_DETAIL,
|
||||||
};
|
};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn estimates_claude_count_tokens_from_system_and_messages() {
|
fn count_tokens_validation_rejects_only_structurally_invalid_requests() {
|
||||||
let payload = json!({
|
assert_eq!(
|
||||||
"model": "claude-sonnet-4-5",
|
validate_claude_count_tokens_request(None),
|
||||||
"system": [{"type": "text", "text": "abcdefghijklmnop"}],
|
Err(CLAUDE_COUNT_TOKENS_BODY_REQUIRED_DETAIL)
|
||||||
"messages": [
|
);
|
||||||
{
|
assert_eq!(
|
||||||
"role": "user",
|
validate_claude_count_tokens_request(Some(&Bytes::from_static(b"{"))),
|
||||||
"content": "abcdefghijkl"
|
Err(CLAUDE_COUNT_TOKENS_INVALID_JSON_DETAIL)
|
||||||
},
|
);
|
||||||
{
|
assert_eq!(
|
||||||
"role": "assistant",
|
validate_claude_count_tokens_request(Some(&Bytes::from_static(br#"{"messages":[]}"#,))),
|
||||||
"content": [
|
Err(CLAUDE_COUNT_TOKENS_MODEL_REQUIRED_DETAIL)
|
||||||
{"type": "text", "text": "abcdefgh"},
|
);
|
||||||
{"type": "tool_use", "name": "ignored", "input": {"city": "SF"}}
|
assert_eq!(
|
||||||
]
|
validate_claude_count_tokens_request(Some(&Bytes::from_static(
|
||||||
}
|
br#"{"model":"claude-sonnet-4-5"}"#,
|
||||||
]
|
))),
|
||||||
});
|
Err(CLAUDE_COUNT_TOKENS_MESSAGES_REQUIRED_DETAIL)
|
||||||
|
);
|
||||||
assert_eq!(estimate_claude_count_tokens(&payload), Ok(17));
|
assert_eq!(
|
||||||
}
|
validate_claude_count_tokens_request(Some(&Bytes::from_static(
|
||||||
|
br#"{"model":"claude-sonnet-4-5","messages":[],"tools":[{"name":"x"}]}"#,
|
||||||
#[test]
|
))),
|
||||||
fn rejects_invalid_claude_count_tokens_payload() {
|
Ok(())
|
||||||
let payload = json!({
|
);
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"messages": [{"role": "system", "content": "bad"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(estimate_claude_count_tokens(&payload), Err(()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
|
|||||||
upstream_is_stream: false,
|
upstream_is_stream: false,
|
||||||
request_query: None,
|
request_query: None,
|
||||||
kiro_api_region: None,
|
kiro_api_region: None,
|
||||||
|
api_operation: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let Some(upstream_url) = upstream_url else {
|
let Some(upstream_url) = upstream_url else {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const FIXED_PROVIDER_RECONCILIATION_LOCK_KEY: &str =
|
|||||||
"task_runtime:lock:maintenance.provider.fixed_template.reconcile";
|
"task_runtime:lock:maintenance.provider.fixed_template.reconcile";
|
||||||
const FIXED_PROVIDER_RECONCILIATION_LOCK_TTL: Duration = Duration::from_secs(10 * 60);
|
const FIXED_PROVIDER_RECONCILIATION_LOCK_TTL: Duration = Duration::from_secs(10 * 60);
|
||||||
const FIXED_PROVIDER_RECONCILIATION_RETRY_DELAY: Duration = Duration::from_secs(2);
|
const FIXED_PROVIDER_RECONCILIATION_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||||
const RECONCILED_PROVIDER_TYPE: &str = "codex";
|
|
||||||
|
|
||||||
pub(crate) async fn perform_fixed_provider_reconciliation_once(
|
pub(crate) async fn perform_fixed_provider_reconciliation_once(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -51,13 +50,9 @@ async fn reconcile_fixed_provider_templates(state: &AppState) -> Result<(), Gate
|
|||||||
let admin_state = AdminAppState::new(state);
|
let admin_state = AdminAppState::new(state);
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
for provider in &providers {
|
for provider in &providers {
|
||||||
if !provider
|
if admin_state
|
||||||
.provider_type
|
.fixed_provider_template(&provider.provider_type)
|
||||||
.trim()
|
.is_none()
|
||||||
.eq_ignore_ascii_case(RECONCILED_PROVIDER_TYPE)
|
|
||||||
|| admin_state
|
|
||||||
.fixed_provider_template(&provider.provider_type)
|
|
||||||
.is_none()
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -226,16 +221,16 @@ mod tests {
|
|||||||
.expect("key should build");
|
.expect("key should build");
|
||||||
key.api_formats = Some(json!(["openai:responses"]));
|
key.api_formats = Some(json!(["openai:responses"]));
|
||||||
|
|
||||||
let unrelated_fixed_provider = StoredProviderCatalogProvider::new(
|
let unrelated_provider = StoredProviderCatalogProvider::new(
|
||||||
"provider-claude-code".to_string(),
|
"provider-custom".to_string(),
|
||||||
"Claude Code".to_string(),
|
"Custom".to_string(),
|
||||||
None,
|
None,
|
||||||
"claude_code".to_string(),
|
"custom".to_string(),
|
||||||
)
|
)
|
||||||
.expect("unrelated fixed provider should build");
|
.expect("unrelated provider should build");
|
||||||
|
|
||||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![provider, unrelated_fixed_provider],
|
vec![provider, unrelated_provider],
|
||||||
vec![responses],
|
vec![responses],
|
||||||
vec![key],
|
vec![key],
|
||||||
));
|
));
|
||||||
@@ -284,7 +279,7 @@ mod tests {
|
|||||||
assert_eq!(keys.len(), 1);
|
assert_eq!(keys.len(), 1);
|
||||||
assert_eq!(keys[0].api_formats, Some(json!(["openai:responses"])));
|
assert_eq!(keys[0].api_formats, Some(json!(["openai:responses"])));
|
||||||
assert!(repository
|
assert!(repository
|
||||||
.list_endpoints_by_provider_ids(&["provider-claude-code".to_string()])
|
.list_endpoints_by_provider_ids(&["provider-custom".to_string()])
|
||||||
.await
|
.await
|
||||||
.expect("unrelated endpoints should list")
|
.expect("unrelated endpoints should list")
|
||||||
.is_empty());
|
.is_empty());
|
||||||
@@ -298,4 +293,106 @@ mod tests {
|
|||||||
.expect("endpoints should list again");
|
.expect("endpoints should list again");
|
||||||
assert_eq!(second_endpoints, first_endpoints);
|
assert_eq!(second_endpoints, first_endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fixed_provider_reconciliation_retires_removed_vertex_claude_endpoint() {
|
||||||
|
let provider = StoredProviderCatalogProvider::new(
|
||||||
|
"provider-vertex".to_string(),
|
||||||
|
"Vertex AI".to_string(),
|
||||||
|
None,
|
||||||
|
"vertex_ai".to_string(),
|
||||||
|
)
|
||||||
|
.expect("provider should build");
|
||||||
|
|
||||||
|
let gemini = StoredProviderCatalogEndpoint::new(
|
||||||
|
"endpoint-vertex-gemini".to_string(),
|
||||||
|
provider.id.clone(),
|
||||||
|
"gemini:generate_content".to_string(),
|
||||||
|
Some("gemini".to_string()),
|
||||||
|
Some("generate_content".to_string()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("gemini endpoint should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
"https://aiplatform.googleapis.com".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(2),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("gemini endpoint transport should build");
|
||||||
|
|
||||||
|
let claude = StoredProviderCatalogEndpoint::new(
|
||||||
|
"endpoint-vertex-claude".to_string(),
|
||||||
|
provider.id.clone(),
|
||||||
|
"claude:messages".to_string(),
|
||||||
|
Some("claude".to_string()),
|
||||||
|
Some("messages".to_string()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("claude endpoint should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
"https://aiplatform.googleapis.com".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(2),
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"_aether_fixed_provider_template": {
|
||||||
|
"managed": true,
|
||||||
|
"provider_type": "vertex_ai",
|
||||||
|
"item_key": "claude:messages",
|
||||||
|
"version": 1,
|
||||||
|
"retired": false,
|
||||||
|
"overrides": [],
|
||||||
|
"config_keys": []
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("claude endpoint transport should build");
|
||||||
|
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![gemini, claude],
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(perform_fixed_provider_reconciliation_once(&state)
|
||||||
|
.await
|
||||||
|
.expect("reconciliation should run"));
|
||||||
|
|
||||||
|
let endpoints = repository
|
||||||
|
.list_endpoints_by_provider_ids(&["provider-vertex".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("vertex endpoints should list");
|
||||||
|
let gemini = endpoints
|
||||||
|
.iter()
|
||||||
|
.find(|endpoint| endpoint.id == "endpoint-vertex-gemini")
|
||||||
|
.expect("gemini endpoint should remain");
|
||||||
|
assert!(gemini.is_active);
|
||||||
|
|
||||||
|
let claude = endpoints
|
||||||
|
.iter()
|
||||||
|
.find(|endpoint| endpoint.id == "endpoint-vertex-claude")
|
||||||
|
.expect("legacy claude endpoint should remain as retired history");
|
||||||
|
assert!(!claude.is_active);
|
||||||
|
assert_eq!(
|
||||||
|
claude
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("_aether_fixed_provider_template"))
|
||||||
|
.and_then(|value| value.get("retired")),
|
||||||
|
Some(&json!(true))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,223 @@ impl LocalFailoverClassification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum FailureRetryAction {
|
||||||
|
Stop,
|
||||||
|
SameCredential,
|
||||||
|
NextCandidate,
|
||||||
|
NextCredential,
|
||||||
|
NextEndpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum FailureScope {
|
||||||
|
None,
|
||||||
|
Credential,
|
||||||
|
CredentialModel,
|
||||||
|
Endpoint,
|
||||||
|
Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailureScope {
|
||||||
|
pub(crate) const fn affects_credential(self) -> bool {
|
||||||
|
matches!(self, Self::Credential | Self::CredentialModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn allows_key_wide_effects(self) -> bool {
|
||||||
|
matches!(self, Self::None | Self::Credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum FailureTokenAction {
|
||||||
|
None,
|
||||||
|
ForceRefresh,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Quarantine,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) struct FailureDisposition {
|
||||||
|
pub(crate) retry_action: FailureRetryAction,
|
||||||
|
pub(crate) failure_scope: FailureScope,
|
||||||
|
pub(crate) token_action: FailureTokenAction,
|
||||||
|
pub(crate) preserve_upstream_error: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FailureDisposition {
|
||||||
|
const fn new(
|
||||||
|
retry_action: FailureRetryAction,
|
||||||
|
failure_scope: FailureScope,
|
||||||
|
token_action: FailureTokenAction,
|
||||||
|
preserve_upstream_error: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
retry_action,
|
||||||
|
failure_scope,
|
||||||
|
token_action,
|
||||||
|
preserve_upstream_error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn failure_disposition_from_local_classification(
|
||||||
|
classification: LocalFailoverClassification,
|
||||||
|
status_code: u16,
|
||||||
|
) -> FailureDisposition {
|
||||||
|
match classification {
|
||||||
|
LocalFailoverClassification::StopStatusCode
|
||||||
|
| LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError
|
||||||
|
| LocalFailoverClassification::StopCyberPolicy => FailureDisposition::new(
|
||||||
|
FailureRetryAction::Stop,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
LocalFailoverClassification::UseDefault => FailureDisposition::new(
|
||||||
|
FailureRetryAction::Stop,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
status_code >= 400,
|
||||||
|
),
|
||||||
|
LocalFailoverClassification::RetrySuccessPattern => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextCandidate,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
LocalFailoverClassification::RetryStatusCode
|
||||||
|
| LocalFailoverClassification::RetryUpstreamFailure => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextCandidate,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const fn classify_anthropic_failure_disposition(
|
||||||
|
classification: LocalFailoverClassification,
|
||||||
|
status_code: u16,
|
||||||
|
) -> FailureDisposition {
|
||||||
|
if matches!(
|
||||||
|
classification,
|
||||||
|
LocalFailoverClassification::StopStatusCode
|
||||||
|
| LocalFailoverClassification::StopErrorPattern
|
||||||
|
| LocalFailoverClassification::StopExecutionError
|
||||||
|
| LocalFailoverClassification::StopCyberPolicy
|
||||||
|
) {
|
||||||
|
let generic = failure_disposition_from_local_classification(classification, status_code);
|
||||||
|
return match status_code {
|
||||||
|
401 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::Credential,
|
||||||
|
FailureTokenAction::ForceRefresh,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
403 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::Credential,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
404 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::Endpoint,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
429 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::CredentialModel,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
529 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::Provider,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
500..=599 => FailureDisposition::new(
|
||||||
|
generic.retry_action,
|
||||||
|
FailureScope::Endpoint,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
generic.preserve_upstream_error,
|
||||||
|
),
|
||||||
|
_ => generic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match status_code {
|
||||||
|
400 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::Stop,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
401 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextCredential,
|
||||||
|
FailureScope::Credential,
|
||||||
|
FailureTokenAction::ForceRefresh,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
403 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextCredential,
|
||||||
|
FailureScope::Credential,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
404 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextEndpoint,
|
||||||
|
FailureScope::Endpoint,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
413 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::Stop,
|
||||||
|
FailureScope::None,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
429 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextCredential,
|
||||||
|
FailureScope::CredentialModel,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
529 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextEndpoint,
|
||||||
|
FailureScope::Provider,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
500..=599 => FailureDisposition::new(
|
||||||
|
FailureRetryAction::NextEndpoint,
|
||||||
|
FailureScope::Endpoint,
|
||||||
|
FailureTokenAction::None,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_ => failure_disposition_from_local_classification(classification, status_code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn classify_failure_disposition(
|
||||||
|
provider_api_format: &str,
|
||||||
|
classification: LocalFailoverClassification,
|
||||||
|
status_code: u16,
|
||||||
|
) -> FailureDisposition {
|
||||||
|
if provider_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
{
|
||||||
|
classify_anthropic_failure_disposition(classification, status_code)
|
||||||
|
} else {
|
||||||
|
failure_disposition_from_local_classification(classification, status_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn classify_local_failover(
|
pub(crate) fn classify_local_failover(
|
||||||
policy: &LocalFailoverPolicy,
|
policy: &LocalFailoverPolicy,
|
||||||
input: LocalFailoverInput<'_>,
|
input: LocalFailoverInput<'_>,
|
||||||
@@ -267,7 +484,11 @@ fn local_failover_regex_rule_matches(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::{classify_local_failover, LocalFailoverClassification, LocalFailoverInput};
|
use super::{
|
||||||
|
classify_anthropic_failure_disposition, classify_local_failover,
|
||||||
|
failure_disposition_from_local_classification, FailureDisposition, FailureRetryAction,
|
||||||
|
FailureScope, FailureTokenAction, LocalFailoverClassification, LocalFailoverInput,
|
||||||
|
};
|
||||||
use crate::orchestration::{LocalFailoverPolicy, LocalFailoverRegexRule};
|
use crate::orchestration::{LocalFailoverPolicy, LocalFailoverRegexRule};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -544,4 +765,138 @@ mod tests {
|
|||||||
LocalFailoverClassification::UseDefault
|
LocalFailoverClassification::UseDefault
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_classification_preserves_candidate_by_candidate_retry() {
|
||||||
|
assert_eq!(
|
||||||
|
failure_disposition_from_local_classification(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
429,
|
||||||
|
),
|
||||||
|
FailureDisposition {
|
||||||
|
retry_action: FailureRetryAction::NextCandidate,
|
||||||
|
failure_scope: FailureScope::None,
|
||||||
|
token_action: FailureTokenAction::None,
|
||||||
|
preserve_upstream_error: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
failure_disposition_from_local_classification(
|
||||||
|
LocalFailoverClassification::StopErrorPattern,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
.retry_action,
|
||||||
|
FailureRetryAction::Stop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_bad_request_stops_and_preserves_upstream_error() {
|
||||||
|
let disposition = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(disposition.retry_action, FailureRetryAction::Stop);
|
||||||
|
assert_eq!(disposition.failure_scope, FailureScope::None);
|
||||||
|
assert_eq!(disposition.token_action, FailureTokenAction::None);
|
||||||
|
assert!(disposition.preserve_upstream_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_auth_failures_refresh_then_rotate_only_when_needed() {
|
||||||
|
let unauthorized = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unauthorized.retry_action,
|
||||||
|
FailureRetryAction::NextCredential
|
||||||
|
);
|
||||||
|
assert_eq!(unauthorized.failure_scope, FailureScope::Credential);
|
||||||
|
assert_eq!(unauthorized.token_action, FailureTokenAction::ForceRefresh);
|
||||||
|
|
||||||
|
let forbidden = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
assert_eq!(forbidden.retry_action, FailureRetryAction::NextCredential);
|
||||||
|
assert_eq!(forbidden.failure_scope, FailureScope::Credential);
|
||||||
|
assert_eq!(forbidden.token_action, FailureTokenAction::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_rate_limit_rotates_with_credential_model_scope() {
|
||||||
|
let disposition = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(disposition.retry_action, FailureRetryAction::NextCredential);
|
||||||
|
assert_eq!(disposition.failure_scope, FailureScope::CredentialModel);
|
||||||
|
assert!(disposition.failure_scope.affects_credential());
|
||||||
|
assert!(!disposition.failure_scope.allows_key_wide_effects());
|
||||||
|
assert!(disposition.preserve_upstream_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_overload_moves_endpoint_without_credential_penalty() {
|
||||||
|
let disposition = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
529,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(disposition.retry_action, FailureRetryAction::NextEndpoint);
|
||||||
|
assert_eq!(disposition.failure_scope, FailureScope::Provider);
|
||||||
|
assert!(!disposition.failure_scope.affects_credential());
|
||||||
|
assert!(!disposition.failure_scope.allows_key_wide_effects());
|
||||||
|
assert_eq!(disposition.token_action, FailureTokenAction::None);
|
||||||
|
assert!(disposition.preserve_upstream_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_not_found_moves_endpoint_and_oversize_stops() {
|
||||||
|
let not_found = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
assert_eq!(not_found.retry_action, FailureRetryAction::NextEndpoint);
|
||||||
|
assert_eq!(not_found.failure_scope, FailureScope::Endpoint);
|
||||||
|
assert!(not_found.preserve_upstream_error);
|
||||||
|
|
||||||
|
let oversized = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
413,
|
||||||
|
);
|
||||||
|
assert_eq!(oversized.retry_action, FailureRetryAction::Stop);
|
||||||
|
assert_eq!(oversized.failure_scope, FailureScope::None);
|
||||||
|
assert!(oversized.preserve_upstream_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_unscoped_and_credential_failures_allow_key_wide_effects() {
|
||||||
|
assert!(FailureScope::None.allows_key_wide_effects());
|
||||||
|
assert!(FailureScope::Credential.allows_key_wide_effects());
|
||||||
|
assert!(!FailureScope::CredentialModel.allows_key_wide_effects());
|
||||||
|
assert!(!FailureScope::Endpoint.allows_key_wide_effects());
|
||||||
|
assert!(!FailureScope::Provider.allows_key_wide_effects());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_explicit_stop_keeps_failure_resource_scope() {
|
||||||
|
let auth = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::StopStatusCode,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
assert_eq!(auth.retry_action, FailureRetryAction::Stop);
|
||||||
|
assert_eq!(auth.failure_scope, FailureScope::Credential);
|
||||||
|
assert_eq!(auth.token_action, FailureTokenAction::ForceRefresh);
|
||||||
|
|
||||||
|
let overloaded = classify_anthropic_failure_disposition(
|
||||||
|
LocalFailoverClassification::StopStatusCode,
|
||||||
|
529,
|
||||||
|
);
|
||||||
|
assert_eq!(overloaded.retry_action, FailureRetryAction::Stop);
|
||||||
|
assert_eq!(overloaded.failure_scope, FailureScope::Provider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ use tokio::sync::Mutex as TokioMutex;
|
|||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
local_failover_error_message, project_local_adaptive_rate_limit,
|
classify_failure_disposition, local_failover_error_message, project_local_adaptive_rate_limit,
|
||||||
project_local_adaptive_success, project_local_failure_health, project_local_key_circuit_closed,
|
project_local_adaptive_success, project_local_failure_health, project_local_key_circuit_closed,
|
||||||
project_local_key_circuit_failure, project_local_success_health, LocalFailoverClassification,
|
project_local_key_circuit_failure, project_local_success_health, FailureScope,
|
||||||
|
LocalFailoverClassification,
|
||||||
};
|
};
|
||||||
use crate::ai_serving::extract_pool_sticky_session_token;
|
use crate::ai_serving::extract_pool_sticky_session_token;
|
||||||
use crate::client_session_affinity::{
|
use crate::client_session_affinity::{
|
||||||
@@ -613,7 +614,8 @@ async fn record_attempt_failure_effect(
|
|||||||
context: LocalExecutionEffectContext<'_>,
|
context: LocalExecutionEffectContext<'_>,
|
||||||
effect: LocalAttemptFailureEffect,
|
effect: LocalAttemptFailureEffect,
|
||||||
) {
|
) {
|
||||||
if !local_candidate_failure_should_invalidate_affinity(
|
if !local_candidate_failure_should_invalidate_affinity_for_provider(
|
||||||
|
&context.plan.provider_api_format,
|
||||||
effect.classification,
|
effect.classification,
|
||||||
effect.status_code,
|
effect.status_code,
|
||||||
) {
|
) {
|
||||||
@@ -683,6 +685,13 @@ async fn record_adaptive_rate_limit_effect(
|
|||||||
context: LocalExecutionEffectContext<'_>,
|
context: LocalExecutionEffectContext<'_>,
|
||||||
effect: LocalAdaptiveRateLimitEffect<'_>,
|
effect: LocalAdaptiveRateLimitEffect<'_>,
|
||||||
) {
|
) {
|
||||||
|
if !local_candidate_failure_should_apply_key_effects(
|
||||||
|
&context.plan.provider_api_format,
|
||||||
|
effect.classification,
|
||||||
|
effect.status_code,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(auth_config_fence) =
|
let Some(auth_config_fence) =
|
||||||
capture_local_execution_auth_config_fence(state, context.plan).await
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
||||||
else {
|
else {
|
||||||
@@ -964,6 +973,13 @@ async fn record_health_failure_effect(
|
|||||||
context: LocalExecutionEffectContext<'_>,
|
context: LocalExecutionEffectContext<'_>,
|
||||||
effect: LocalHealthFailureEffect,
|
effect: LocalHealthFailureEffect,
|
||||||
) {
|
) {
|
||||||
|
if !local_candidate_failure_should_apply_key_effects(
|
||||||
|
&context.plan.provider_api_format,
|
||||||
|
effect.classification,
|
||||||
|
effect.status_code,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let api_format = context.plan.provider_api_format.trim();
|
let api_format = context.plan.provider_api_format.trim();
|
||||||
if api_format.is_empty() {
|
if api_format.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -1231,6 +1247,13 @@ async fn record_pool_error_effect(
|
|||||||
context: LocalExecutionEffectContext<'_>,
|
context: LocalExecutionEffectContext<'_>,
|
||||||
effect: LocalPoolErrorEffect<'_>,
|
effect: LocalPoolErrorEffect<'_>,
|
||||||
) {
|
) {
|
||||||
|
if !local_candidate_failure_should_apply_key_effects(
|
||||||
|
&context.plan.provider_api_format,
|
||||||
|
effect.classification,
|
||||||
|
effect.status_code,
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let terminal_error_reason =
|
let terminal_error_reason =
|
||||||
admin_provider_pool_key_terminal_error_reason(effect.status_code, effect.error_body);
|
admin_provider_pool_key_terminal_error_reason(effect.status_code, effect.error_body);
|
||||||
if terminal_error_reason.is_none()
|
if terminal_error_reason.is_none()
|
||||||
@@ -1379,13 +1402,7 @@ async fn record_oauth_invalidation_effect(
|
|||||||
if !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
if !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if transport
|
if !execution_plan_bearer_matches_transport(plan, &transport) {
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("codex")
|
|
||||||
&& !execution_plan_bearer_matches_transport(plan, &transport)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,28 +1414,8 @@ async fn record_oauth_invalidation_effect(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let expected_auth_config = match state
|
|
||||||
.capture_provider_transport_auth_config_fence(&transport)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(ciphertext)) => ciphertext,
|
|
||||||
Ok(None) => return,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"gateway orchestration effects: failed to capture oauth invalidation fence for provider {} endpoint {} key {}: {:?}",
|
|
||||||
plan.provider_id, plan.endpoint_id, plan.key_id, err
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.mark_provider_catalog_key_oauth_invalid_fenced(
|
.mark_provider_transport_oauth_invalid_fenced(&transport, invalid_reason.as_str())
|
||||||
&plan.key_id,
|
|
||||||
transport.provider.provider_type.as_str(),
|
|
||||||
invalid_reason.as_str(),
|
|
||||||
expected_auth_config.as_str(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1444,16 +1441,20 @@ fn execution_plan_bearer_matches_transport(
|
|||||||
plan: &ExecutionPlan,
|
plan: &ExecutionPlan,
|
||||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let current_token = transport.key.decrypted_api_key.trim();
|
let Some(plan_token) = execution_plan_authorization(plan).and_then(bearer_access_token) else {
|
||||||
!current_token.is_empty()
|
return false;
|
||||||
&& plan.headers.iter().any(|(name, value)| {
|
};
|
||||||
name.eq_ignore_ascii_case("authorization")
|
crate::provider_transport::resolve_local_generic_oauth_transport_authorization(transport)
|
||||||
&& value
|
.as_deref()
|
||||||
.trim()
|
.and_then(bearer_access_token)
|
||||||
.strip_prefix("Bearer ")
|
.is_some_and(|current_token| current_token == plan_token)
|
||||||
.map(str::trim)
|
}
|
||||||
.is_some_and(|token| token == current_token)
|
|
||||||
})
|
fn bearer_access_token(authorization: &str) -> Option<&str> {
|
||||||
|
let mut parts = authorization.split_ascii_whitespace();
|
||||||
|
let scheme = parts.next()?;
|
||||||
|
let token = parts.next()?;
|
||||||
|
(scheme.eq_ignore_ascii_case("bearer") && parts.next().is_none()).then_some(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_local_oauth_invalid_reason(
|
fn resolve_local_oauth_invalid_reason(
|
||||||
@@ -1467,6 +1468,12 @@ fn resolve_local_oauth_invalid_reason(
|
|||||||
status_code,
|
status_code,
|
||||||
upstream_message.as_deref(),
|
upstream_message.as_deref(),
|
||||||
),
|
),
|
||||||
|
_ if super::oauth_status_may_be_invalid(status_code, response_text) => Some(format!(
|
||||||
|
"[OAUTH_EXPIRED] {}",
|
||||||
|
upstream_message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("OAuth access token was rejected")
|
||||||
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1492,6 +1499,46 @@ fn local_candidate_failure_should_invalidate_affinity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_candidate_failure_should_invalidate_affinity_for_provider(
|
||||||
|
provider_api_format: &str,
|
||||||
|
classification: LocalFailoverClassification,
|
||||||
|
status_code: u16,
|
||||||
|
) -> bool {
|
||||||
|
if !local_candidate_failure_should_invalidate_affinity(classification, status_code) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !provider_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposition =
|
||||||
|
classify_failure_disposition(provider_api_format, classification, status_code);
|
||||||
|
!(disposition.retry_action == crate::orchestration::FailureRetryAction::Stop
|
||||||
|
&& disposition.failure_scope == FailureScope::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_candidate_failure_should_apply_key_effects(
|
||||||
|
provider_api_format: &str,
|
||||||
|
classification: LocalFailoverClassification,
|
||||||
|
status_code: u16,
|
||||||
|
) -> bool {
|
||||||
|
if !provider_api_format
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("claude:messages")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches!(
|
||||||
|
classify_failure_disposition(provider_api_format, classification, status_code)
|
||||||
|
.failure_scope,
|
||||||
|
FailureScope::Credential
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn local_candidate_failure_should_record_pool_error(
|
fn local_candidate_failure_should_record_pool_error(
|
||||||
classification: LocalFailoverClassification,
|
classification: LocalFailoverClassification,
|
||||||
status_code: u16,
|
status_code: u16,
|
||||||
@@ -1708,12 +1755,13 @@ mod tests {
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
apply_local_execution_effect, local_candidate_failure_should_record_pool_error,
|
apply_local_execution_effect, execution_plan_bearer_matches_transport,
|
||||||
pool_score_feedback_gate_allows, pool_score_hard_state_for_status,
|
local_candidate_failure_should_apply_key_effects,
|
||||||
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
|
local_candidate_failure_should_record_pool_error, pool_score_feedback_gate_allows,
|
||||||
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
|
pool_score_hard_state_for_status, LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect,
|
||||||
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
|
LocalAttemptFailureEffect, LocalExecutionEffect, LocalExecutionEffectContext,
|
||||||
ProviderKeyEffectLockPool,
|
LocalHealthFailureEffect, LocalHealthSuccessEffect, LocalOAuthInvalidationEffect,
|
||||||
|
LocalPoolErrorEffect, ProviderKeyEffectLockPool,
|
||||||
};
|
};
|
||||||
use crate::data::{GatewayDataConfig, GatewayDataState};
|
use crate::data::{GatewayDataConfig, GatewayDataState};
|
||||||
use crate::orchestration::LocalFailoverClassification;
|
use crate::orchestration::LocalFailoverClassification;
|
||||||
@@ -1760,6 +1808,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_claude_plan() -> ExecutionPlan {
|
||||||
|
let mut plan = sample_plan();
|
||||||
|
plan.provider_name = Some("anthropic".to_string());
|
||||||
|
plan.provider_api_format = "claude:messages".to_string();
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pool_score_feedback_gate_suppresses_repeated_success_writes() {
|
fn pool_score_feedback_gate_suppresses_repeated_success_writes() {
|
||||||
super::POOL_SCORE_FEEDBACK_GATE.clear();
|
super::POOL_SCORE_FEEDBACK_GATE.clear();
|
||||||
@@ -1864,7 +1919,7 @@ mod tests {
|
|||||||
url: "https://chatgpt.com/backend-api/codex".to_string(),
|
url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||||
headers: BTreeMap::from([(
|
headers: BTreeMap::from([(
|
||||||
"authorization".to_string(),
|
"authorization".to_string(),
|
||||||
"Bearer __placeholder__".to_string(),
|
"Bearer codex-access-token".to_string(),
|
||||||
)]),
|
)]),
|
||||||
content_type: Some("application/json".to_string()),
|
content_type: Some("application/json".to_string()),
|
||||||
content_encoding: None,
|
content_encoding: None,
|
||||||
@@ -1959,8 +2014,8 @@ mod tests {
|
|||||||
.expect("key should build")
|
.expect("key should build")
|
||||||
.with_transport_fields(
|
.with_transport_fields(
|
||||||
Some(serde_json::json!(["openai:responses"])),
|
Some(serde_json::json!(["openai:responses"])),
|
||||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "codex-access-token")
|
||||||
.expect("placeholder api key should encrypt"),
|
.expect("access token should encrypt"),
|
||||||
Some(encrypted_auth_config),
|
Some(encrypted_auth_config),
|
||||||
None,
|
None,
|
||||||
Some(serde_json::json!({"openai:responses": 1})),
|
Some(serde_json::json!({"openai:responses": 1})),
|
||||||
@@ -1975,6 +2030,10 @@ mod tests {
|
|||||||
fn sample_codex_agent_identity_key() -> StoredProviderCatalogKey {
|
fn sample_codex_agent_identity_key() -> StoredProviderCatalogKey {
|
||||||
let mut key = sample_codex_key();
|
let mut key = sample_codex_key();
|
||||||
key.name = "Agent Identity".to_string();
|
key.name = "Agent Identity".to_string();
|
||||||
|
key.encrypted_api_key = Some(
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
||||||
|
.expect("placeholder api key should encrypt"),
|
||||||
|
);
|
||||||
key.encrypted_auth_config = Some(
|
key.encrypted_auth_config = Some(
|
||||||
encrypt_python_fernet_plaintext(
|
encrypt_python_fernet_plaintext(
|
||||||
DEVELOPMENT_ENCRYPTION_KEY,
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
@@ -2014,6 +2073,36 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_code_oauth_state() -> AppState {
|
||||||
|
let mut provider = sample_codex_provider();
|
||||||
|
provider.name = "claude_code".to_string();
|
||||||
|
provider.provider_type = "claude_code".to_string();
|
||||||
|
let mut endpoint = sample_codex_endpoint();
|
||||||
|
endpoint.api_format = "claude:messages".to_string();
|
||||||
|
endpoint.api_family = Some("claude".to_string());
|
||||||
|
endpoint.base_url = "https://api.anthropic.com".to_string();
|
||||||
|
let mut key = sample_codex_key();
|
||||||
|
key.api_formats = Some(json!(["claude:messages"]));
|
||||||
|
key.encrypted_auth_config = Some(
|
||||||
|
encrypt_python_fernet_plaintext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
r#"{"provider_type":"claude_code","refresh_token":"rt-claude-local-123"}"#,
|
||||||
|
)
|
||||||
|
.expect("Claude Code auth config should encrypt"),
|
||||||
|
);
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![endpoint],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn codex_state_with_redis(redis_url: &str, redis_key_prefix: &str) -> AppState {
|
fn codex_state_with_redis(redis_url: &str, redis_key_prefix: &str) -> AppState {
|
||||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![sample_codex_provider()],
|
vec![sample_codex_provider()],
|
||||||
@@ -2724,6 +2813,179 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_non_credential_failures_do_not_apply_key_wide_effects() {
|
||||||
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
529,
|
||||||
|
));
|
||||||
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
429,
|
||||||
|
));
|
||||||
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
503,
|
||||||
|
));
|
||||||
|
assert!(local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
401,
|
||||||
|
));
|
||||||
|
assert!(local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
403,
|
||||||
|
));
|
||||||
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
||||||
|
"claude:messages",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
400,
|
||||||
|
));
|
||||||
|
assert!(local_candidate_failure_should_apply_key_effects(
|
||||||
|
"openai:chat",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
529,
|
||||||
|
));
|
||||||
|
assert!(local_candidate_failure_should_apply_key_effects(
|
||||||
|
"openai:chat",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
429,
|
||||||
|
));
|
||||||
|
assert!(local_candidate_failure_should_apply_key_effects(
|
||||||
|
"openai:chat",
|
||||||
|
LocalFailoverClassification::RetryUpstreamFailure,
|
||||||
|
503,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_non_credential_failures_preserve_key_wide_state() {
|
||||||
|
for status_code in [400, 429, 503, 529] {
|
||||||
|
let mut key = sample_adaptive_key();
|
||||||
|
let circuit = json!({
|
||||||
|
"openai:chat": {
|
||||||
|
"open": true,
|
||||||
|
"reason": "existing-state"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
key.circuit_breaker_by_format = Some(circuit.clone());
|
||||||
|
let expected_adaptive_state = ProviderCatalogKeyAdaptiveState::from(&key);
|
||||||
|
let expected_health = key.health_by_format.clone();
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_pool_health_provider()],
|
||||||
|
vec![sample_health_endpoint()],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
);
|
||||||
|
let plan = sample_claude_plan();
|
||||||
|
let report_context = json!({
|
||||||
|
"api_key_id": "api-key-1",
|
||||||
|
"client_api_format": "claude:messages",
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
});
|
||||||
|
let cache_key = build_scheduler_affinity_cache_key_for_api_key_id(
|
||||||
|
"api-key-1",
|
||||||
|
"claude:messages",
|
||||||
|
"claude-sonnet-4-5",
|
||||||
|
)
|
||||||
|
.expect("scheduler affinity cache key should build");
|
||||||
|
let target = SchedulerAffinityTarget {
|
||||||
|
provider_id: plan.provider_id.clone(),
|
||||||
|
endpoint_id: plan.endpoint_id.clone(),
|
||||||
|
key_id: plan.key_id.clone(),
|
||||||
|
};
|
||||||
|
state.remember_scheduler_affinity_target(
|
||||||
|
&cache_key,
|
||||||
|
target.clone(),
|
||||||
|
SCHEDULER_AFFINITY_TTL,
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
let headers = BTreeMap::from([("Retry-After".to_string(), "120".to_string())]);
|
||||||
|
let context = LocalExecutionEffectContext {
|
||||||
|
plan: &plan,
|
||||||
|
report_context: Some(&report_context),
|
||||||
|
};
|
||||||
|
let classification = LocalFailoverClassification::RetryUpstreamFailure;
|
||||||
|
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
context,
|
||||||
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
||||||
|
status_code,
|
||||||
|
classification,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
context,
|
||||||
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
||||||
|
status_code,
|
||||||
|
classification,
|
||||||
|
headers: Some(&headers),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
context,
|
||||||
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
||||||
|
status_code,
|
||||||
|
classification,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
context,
|
||||||
|
LocalExecutionEffect::PoolError(LocalPoolErrorEffect {
|
||||||
|
status_code,
|
||||||
|
classification,
|
||||||
|
headers: &headers,
|
||||||
|
error_body: Some(r#"{"error":{"message":"temporarily unavailable"}}"#),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let stored_key = state
|
||||||
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
||||||
|
.await
|
||||||
|
.expect("provider catalog keys should load")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("stored key should exist");
|
||||||
|
assert_eq!(
|
||||||
|
ProviderCatalogKeyAdaptiveState::from(&stored_key),
|
||||||
|
expected_adaptive_state,
|
||||||
|
"Anthropic status {status_code} must not update key-wide adaptive state"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stored_key.health_by_format, expected_health,
|
||||||
|
"Anthropic status {status_code} must not update key-wide health"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stored_key.circuit_breaker_by_format,
|
||||||
|
Some(circuit),
|
||||||
|
"Anthropic status {status_code} must not clear pool key state"
|
||||||
|
);
|
||||||
|
let expected_affinity = (status_code == 400).then_some(target);
|
||||||
|
assert_eq!(
|
||||||
|
state.read_scheduler_affinity_target(&cache_key, SCHEDULER_AFFINITY_TTL),
|
||||||
|
expected_affinity,
|
||||||
|
"Anthropic status {status_code} must invalidate only retryable target affinity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_pool_account_errors_project_pool_hard_state() {
|
fn terminal_pool_account_errors_project_pool_hard_state() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2790,6 +3052,162 @@ mod tests {
|
|||||||
assert_eq!(stored_key.circuit_breaker_by_format, None);
|
assert_eq!(stored_key.circuit_breaker_by_format, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oauth_bearer_generation_match_supports_generic_auth_config_token() {
|
||||||
|
let state = codex_state();
|
||||||
|
let mut transport = state
|
||||||
|
.read_provider_transport_snapshot(
|
||||||
|
"provider-codex-cli-local-1",
|
||||||
|
"endpoint-codex-cli-local-1",
|
||||||
|
"key-codex-cli-local-1",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("transport should load")
|
||||||
|
.expect("transport should exist");
|
||||||
|
transport.provider.provider_type = "claude_code".to_string();
|
||||||
|
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||||
|
transport.key.decrypted_auth_config =
|
||||||
|
Some(json!({"accessToken": "current-access-token"}).to_string());
|
||||||
|
let mut plan = sample_codex_plan();
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer current-access-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer stale-access-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
|
||||||
|
transport.key.decrypted_api_key = "replacement-access-token".to_string();
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer current-access-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer replacement-access-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
|
||||||
|
transport.key.decrypted_auth_config = Some(
|
||||||
|
json!({
|
||||||
|
"accessToken": "current-access-token",
|
||||||
|
"request": {
|
||||||
|
"extraHeaders": {
|
||||||
|
"Authorization": "Bearer nested-override-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer replacement-access-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
plan.headers.insert(
|
||||||
|
"authorization".to_string(),
|
||||||
|
"Bearer nested-override-token".to_string(),
|
||||||
|
);
|
||||||
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oauth_invalidation_marks_claude_code_authentication_failures_only() {
|
||||||
|
let state = claude_code_oauth_state();
|
||||||
|
let mut plan = sample_codex_plan();
|
||||||
|
plan.provider_name = Some("claude_code".to_string());
|
||||||
|
plan.provider_api_format = "claude:messages".to_string();
|
||||||
|
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
LocalExecutionEffectContext {
|
||||||
|
plan: &plan,
|
||||||
|
report_context: None,
|
||||||
|
},
|
||||||
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
||||||
|
status_code: 403,
|
||||||
|
response_text: Some(
|
||||||
|
r#"{"type":"error","error":{"type":"permission_error","message":"insufficient scope"}}"#,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let unmarked = state
|
||||||
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
||||||
|
.await
|
||||||
|
.expect("provider catalog keys should load")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("stored key should exist");
|
||||||
|
assert!(unmarked.oauth_invalid_at_unix_secs.is_none());
|
||||||
|
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
LocalExecutionEffectContext {
|
||||||
|
plan: &plan,
|
||||||
|
report_context: None,
|
||||||
|
},
|
||||||
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
||||||
|
status_code: 403,
|
||||||
|
response_text: Some(
|
||||||
|
r#"{"type":"error","error":{"type":"authentication_error","message":"invalid access token"}}"#,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let marked = state
|
||||||
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
||||||
|
.await
|
||||||
|
.expect("provider catalog keys should load")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("stored key should exist");
|
||||||
|
assert!(marked.oauth_invalid_at_unix_secs.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
marked.oauth_invalid_reason.as_deref(),
|
||||||
|
Some("[OAUTH_EXPIRED] invalid access token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oauth_invalidation_marks_claude_code_unauthorized_without_body() {
|
||||||
|
let state = claude_code_oauth_state();
|
||||||
|
let mut plan = sample_codex_plan();
|
||||||
|
plan.provider_name = Some("claude_code".to_string());
|
||||||
|
plan.provider_api_format = "claude:messages".to_string();
|
||||||
|
|
||||||
|
apply_local_execution_effect(
|
||||||
|
&state,
|
||||||
|
LocalExecutionEffectContext {
|
||||||
|
plan: &plan,
|
||||||
|
report_context: None,
|
||||||
|
},
|
||||||
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
||||||
|
status_code: 401,
|
||||||
|
response_text: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let stored_key = state
|
||||||
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
||||||
|
.await
|
||||||
|
.expect("provider catalog keys should load")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("stored key should exist");
|
||||||
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
stored_key.oauth_invalid_reason.as_deref(),
|
||||||
|
Some("[OAUTH_EXPIRED] OAuth access token was rejected")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn oauth_invalidation_marks_codex_key_expired() {
|
async fn oauth_invalidation_marks_codex_key_expired() {
|
||||||
let state = codex_state();
|
let state = codex_state();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod attempt;
|
|||||||
mod classifier;
|
mod classifier;
|
||||||
mod effects;
|
mod effects;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod oauth_error;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
mod report_effects;
|
mod report_effects;
|
||||||
@@ -24,8 +25,10 @@ pub(crate) use self::attempt::{
|
|||||||
LocalExecutionCandidateMetadata, SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD,
|
LocalExecutionCandidateMetadata, SCHEDULER_AFFINITY_EPOCH_REPORT_FIELD,
|
||||||
};
|
};
|
||||||
pub(crate) use self::classifier::{
|
pub(crate) use self::classifier::{
|
||||||
classify_local_failover, local_failover_error_message, LocalFailoverClassification,
|
classify_anthropic_failure_disposition, classify_failure_disposition, classify_local_failover,
|
||||||
LocalFailoverInput,
|
failure_disposition_from_local_classification, local_failover_error_message,
|
||||||
|
FailureDisposition, FailureRetryAction, FailureScope, FailureTokenAction,
|
||||||
|
LocalFailoverClassification, LocalFailoverInput,
|
||||||
};
|
};
|
||||||
pub(crate) use self::effects::{
|
pub(crate) use self::effects::{
|
||||||
apply_local_execution_effect, LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect,
|
apply_local_execution_effect, LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect,
|
||||||
@@ -37,6 +40,9 @@ pub(crate) use self::health::{
|
|||||||
project_local_failure_health, project_local_key_circuit_closed,
|
project_local_failure_health, project_local_key_circuit_closed,
|
||||||
project_local_key_circuit_failure, project_local_success_health,
|
project_local_key_circuit_failure, project_local_success_health,
|
||||||
};
|
};
|
||||||
|
pub(crate) use self::oauth_error::{
|
||||||
|
oauth_status_may_be_invalid, oauth_status_proves_access_token_invalid,
|
||||||
|
};
|
||||||
pub(crate) use self::policy::{
|
pub(crate) use self::policy::{
|
||||||
append_local_failover_policy_to_value, codex_cyber_flag_passthrough_enabled,
|
append_local_failover_policy_to_value, codex_cyber_flag_passthrough_enabled,
|
||||||
cyber_continue_failover_enabled, local_failover_policy_from_report_context,
|
cyber_continue_failover_enabled, local_failover_policy_from_report_context,
|
||||||
@@ -44,8 +50,8 @@ pub(crate) use self::policy::{
|
|||||||
LocalFailoverRegexRule, CYBER_CONTINUE_FAILOVER_CONFIG_KEY,
|
LocalFailoverRegexRule, CYBER_CONTINUE_FAILOVER_CONFIG_KEY,
|
||||||
};
|
};
|
||||||
pub(crate) use self::recovery::{
|
pub(crate) use self::recovery::{
|
||||||
analyze_local_failover, recover_local_failover_decision, LocalFailoverAnalysis,
|
analyze_local_failover, apply_provider_failure_disposition, recover_local_failover_decision,
|
||||||
LocalFailoverDecision,
|
LocalFailoverAnalysis, LocalFailoverDecision,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use self::report_effects::clear_local_report_effect_caches_for_tests;
|
pub(crate) use self::report_effects::clear_local_report_effect_caches_for_tests;
|
||||||
@@ -65,7 +71,9 @@ pub(crate) async fn resolve_local_failover_analysis_for_attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let policy = resolve_local_failover_policy(state, plan, report_context).await;
|
let policy = resolve_local_failover_policy(state, plan, report_context).await;
|
||||||
analyze_local_failover(&policy, LocalFailoverInput::new(status_code, response_text))
|
let analysis =
|
||||||
|
analyze_local_failover(&policy, LocalFailoverInput::new(status_code, response_text));
|
||||||
|
apply_provider_failure_disposition(&plan.provider_api_format, status_code, analysis)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn resolve_local_failover_decision_for_attempt(
|
pub(crate) async fn resolve_local_failover_decision_for_attempt(
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
pub(crate) fn oauth_status_may_be_invalid(status_code: u16, response_text: Option<&str>) -> bool {
|
||||||
|
if status_code == 401 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if status_code != 403 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(response_text) = response_text else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Ok(body) = serde_json::from_str::<serde_json::Value>(response_text) {
|
||||||
|
let error_type = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|error| error.get("type"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
body.get("type")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("error"))
|
||||||
|
});
|
||||||
|
if let Some(error_type) = error_type {
|
||||||
|
return is_oauth_invalid_error_taxonomy(error_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
let error_code = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|error| error.get("code"))
|
||||||
|
.or_else(|| body.get("code"))
|
||||||
|
.or_else(|| body.get("error").filter(|error| error.is_string()))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if error_code.is_some_and(is_oauth_invalid_error_taxonomy) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response_has_oauth_invalid_phrase(response_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
response_has_oauth_invalid_phrase(response_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn oauth_status_proves_access_token_invalid(
|
||||||
|
status_code: u16,
|
||||||
|
response_text: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if status_code == 401 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if status_code != 403 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
response_text.is_some_and(response_has_oauth_invalid_phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_oauth_invalid_error_taxonomy(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"authentication_error"
|
||||||
|
| "invalid_authentication_token"
|
||||||
|
| "invalid_token"
|
||||||
|
| "oauth_token_invalid"
|
||||||
|
| "token_invalid"
|
||||||
|
| "token_expired"
|
||||||
|
| "unauthenticated"
|
||||||
|
| "biscuit_baker_service_auth_credential_error_status"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_has_oauth_invalid_phrase(response_text: &str) -> bool {
|
||||||
|
let response_text = response_text.to_ascii_lowercase();
|
||||||
|
if [
|
||||||
|
"oauth_token_invalid",
|
||||||
|
"invalid_token",
|
||||||
|
"biscuit_baker_service_auth_credential_error_status",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|taxonomy| contains_ascii_taxonomy_token(&response_text, taxonomy))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
"oauth token is invalid",
|
||||||
|
"oauth token is expired",
|
||||||
|
"oauth token has expired",
|
||||||
|
"invalid access token",
|
||||||
|
"access token invalid",
|
||||||
|
"access token expired",
|
||||||
|
"expired access token",
|
||||||
|
"authentication token has been invalidated",
|
||||||
|
"token has been invalidated",
|
||||||
|
"personal access token owner is inactive",
|
||||||
|
"security token included in the request is expired",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| response_text.contains(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_ascii_taxonomy_token(text: &str, taxonomy: &str) -> bool {
|
||||||
|
text.match_indices(taxonomy).any(|(start, matched)| {
|
||||||
|
let end = start + matched.len();
|
||||||
|
let is_identifier_byte = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_';
|
||||||
|
let has_left_boundary = start == 0 || !is_identifier_byte(text.as_bytes()[start - 1]);
|
||||||
|
let has_right_boundary = end == text.len() || !is_identifier_byte(text.as_bytes()[end]);
|
||||||
|
has_left_boundary && has_right_boundary
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
use super::classifier::{classify_local_failover, LocalFailoverClassification, LocalFailoverInput};
|
use super::classifier::{
|
||||||
|
classify_failure_disposition, classify_local_failover, FailureRetryAction,
|
||||||
|
LocalFailoverClassification, LocalFailoverInput,
|
||||||
|
};
|
||||||
use super::LocalFailoverPolicy;
|
use super::LocalFailoverPolicy;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -44,6 +47,37 @@ pub(crate) fn analyze_local_failover(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_provider_failure_disposition(
|
||||||
|
provider_api_format: &str,
|
||||||
|
status_code: u16,
|
||||||
|
analysis: LocalFailoverAnalysis,
|
||||||
|
) -> LocalFailoverAnalysis {
|
||||||
|
if status_code < 400
|
||||||
|
&& matches!(
|
||||||
|
analysis.classification,
|
||||||
|
LocalFailoverClassification::UseDefault
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposition =
|
||||||
|
classify_failure_disposition(provider_api_format, analysis.classification, status_code);
|
||||||
|
let decision = match disposition.retry_action {
|
||||||
|
FailureRetryAction::Stop | FailureRetryAction::SameCredential => {
|
||||||
|
LocalFailoverDecision::StopLocalFailover
|
||||||
|
}
|
||||||
|
FailureRetryAction::NextCandidate
|
||||||
|
| FailureRetryAction::NextCredential
|
||||||
|
| FailureRetryAction::NextEndpoint => LocalFailoverDecision::RetryNextCandidate,
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalFailoverAnalysis {
|
||||||
|
classification: analysis.classification,
|
||||||
|
decision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn recover_local_failover_decision(
|
pub(crate) fn recover_local_failover_decision(
|
||||||
policy: &LocalFailoverPolicy,
|
policy: &LocalFailoverPolicy,
|
||||||
input: LocalFailoverInput<'_>,
|
input: LocalFailoverInput<'_>,
|
||||||
@@ -70,7 +104,10 @@ const fn decision_from_classification(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{analyze_local_failover, recover_local_failover_decision, LocalFailoverDecision};
|
use super::{
|
||||||
|
analyze_local_failover, apply_provider_failure_disposition,
|
||||||
|
recover_local_failover_decision, LocalFailoverAnalysis, LocalFailoverDecision,
|
||||||
|
};
|
||||||
use crate::orchestration::{
|
use crate::orchestration::{
|
||||||
LocalFailoverClassification, LocalFailoverInput, LocalFailoverPolicy,
|
LocalFailoverClassification, LocalFailoverInput, LocalFailoverPolicy,
|
||||||
};
|
};
|
||||||
@@ -161,4 +198,44 @@ mod tests {
|
|||||||
LocalFailoverClassification::StopCyberPolicy
|
LocalFailoverClassification::StopCyberPolicy
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_failure_disposition_controls_candidate_retry() {
|
||||||
|
let policy = LocalFailoverPolicy::default();
|
||||||
|
|
||||||
|
for status_code in [400, 413] {
|
||||||
|
let analysis = analyze_local_failover(
|
||||||
|
&policy,
|
||||||
|
LocalFailoverInput::new(status_code, Some(r#"{"error":{"message":"failed"}}"#)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
apply_provider_failure_disposition("claude:messages", status_code, analysis,)
|
||||||
|
.decision,
|
||||||
|
LocalFailoverDecision::StopLocalFailover,
|
||||||
|
"Anthropic status {status_code} must not blindly rotate credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for status_code in [401, 403, 404, 429, 529] {
|
||||||
|
let analysis = analyze_local_failover(
|
||||||
|
&policy,
|
||||||
|
LocalFailoverInput::new(status_code, Some(r#"{"error":{"message":"failed"}}"#)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
apply_provider_failure_disposition("claude:messages", status_code, analysis,)
|
||||||
|
.decision,
|
||||||
|
LocalFailoverDecision::RetryNextCandidate,
|
||||||
|
"Anthropic status {status_code} should continue candidate failover"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_failure_disposition_preserves_non_failure_default() {
|
||||||
|
let analysis = LocalFailoverAnalysis::use_default();
|
||||||
|
assert_eq!(
|
||||||
|
apply_provider_failure_disposition("claude:messages", 200, analysis).decision,
|
||||||
|
LocalFailoverDecision::UseDefault
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,9 @@ mod tests {
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("openai".to_string()),
|
route_family: Some("openai".to_string()),
|
||||||
route_kind: Some("chat".to_string()),
|
route_kind: Some("chat".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: Some("openai:chat".to_string()),
|
auth_endpoint_signature: Some("openai:chat".to_string()),
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use aether_contracts::{
|
|||||||
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
ProviderCatalogKeyOAuthRuntimeStateCasUpdate, ProviderCatalogKeyStatusSnapshotUpdate,
|
ProviderCatalogKeyOAuthCredentialFence, ProviderCatalogKeyOAuthRuntimeStateCasUpdate,
|
||||||
StoredProviderCatalogKey,
|
ProviderCatalogKeyStatusSnapshotUpdate, StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
use aether_runtime_state::RuntimeLockLease;
|
use aether_runtime_state::RuntimeLockLease;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
@@ -42,6 +42,12 @@ const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
|
|||||||
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
|
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
|
||||||
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
|
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct ProviderTransportCredentialFence {
|
||||||
|
encrypted_auth_config: String,
|
||||||
|
credential: ProviderCatalogKeyOAuthCredentialFence,
|
||||||
|
}
|
||||||
|
|
||||||
struct GatewayLocalOAuthHttpExecutor<'a> {
|
struct GatewayLocalOAuthHttpExecutor<'a> {
|
||||||
state: &'a AppState,
|
state: &'a AppState,
|
||||||
}
|
}
|
||||||
@@ -173,32 +179,6 @@ fn local_oauth_transport_context_allows_reload(
|
|||||||
initial, current,
|
initial, current,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if initial
|
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("codex")
|
|
||||||
&& initial.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
|
||||||
{
|
|
||||||
let initial_config = initial
|
|
||||||
.key
|
|
||||||
.decrypted_auth_config
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
|
||||||
let current_config = current
|
|
||||||
.key
|
|
||||||
.decrypted_auth_config
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
|
||||||
return current
|
|
||||||
.provider
|
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("codex")
|
|
||||||
&& current.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
|
||||||
&& initial_config == current_config
|
|
||||||
&& initial.key.decrypted_api_key == current.key.decrypted_api_key;
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1380,7 +1360,7 @@ impl AppState {
|
|||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let expected_auth_config = if current_transport
|
let expected_credential_fence = if current_transport
|
||||||
.key
|
.key
|
||||||
.decrypted_auth_config
|
.decrypted_auth_config
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1388,10 +1368,10 @@ impl AppState {
|
|||||||
.is_some_and(|value| !value.is_empty())
|
.is_some_and(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
match self
|
match self
|
||||||
.capture_provider_transport_auth_config_fence(¤t_transport)
|
.capture_provider_transport_credential_fence(¤t_transport)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Some(ciphertext) => Some(ciphertext),
|
Some(fence) => Some(fence),
|
||||||
None => {
|
None => {
|
||||||
let Some(reloaded) = self
|
let Some(reloaded) = self
|
||||||
.read_provider_transport_snapshot_uncached(
|
.read_provider_transport_snapshot_uncached(
|
||||||
@@ -1485,7 +1465,7 @@ impl AppState {
|
|||||||
.persist_local_oauth_refresh_entry(
|
.persist_local_oauth_refresh_entry(
|
||||||
¤t_transport,
|
¤t_transport,
|
||||||
&refreshed_entry,
|
&refreshed_entry,
|
||||||
expected_auth_config.as_deref(),
|
expected_credential_fence.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1533,9 +1513,9 @@ impl AppState {
|
|||||||
let lock_owner = format!("aether-gateway-admin-{}", std::process::id());
|
let lock_owner = format!("aether-gateway-admin-{}", std::process::id());
|
||||||
let initial_transport = transport.clone();
|
let initial_transport = transport.clone();
|
||||||
let mut current_transport = transport.clone();
|
let mut current_transport = transport.clone();
|
||||||
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
|
let expected_refresh_fingerprint = self
|
||||||
let expected_refresh_fingerprint =
|
.oauth_refresh
|
||||||
provider_transport::codex_agent_identity_refresh_fingerprint(¤t_transport, None);
|
.refresh_fingerprint_for_transport(&initial_transport);
|
||||||
let executor = GatewayLocalOAuthHttpExecutor { state: self };
|
let executor = GatewayLocalOAuthHttpExecutor { state: self };
|
||||||
let transport_refresh_token_fingerprint = oauth_auth_config_refresh_token_fingerprint(
|
let transport_refresh_token_fingerprint = oauth_auth_config_refresh_token_fingerprint(
|
||||||
current_transport.key.decrypted_auth_config.as_deref(),
|
current_transport.key.decrypted_auth_config.as_deref(),
|
||||||
@@ -1560,8 +1540,8 @@ impl AppState {
|
|||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let expected_auth_config = match self
|
let expected_credential_fence = match self
|
||||||
.capture_provider_transport_auth_config_fence(¤t_transport)
|
.capture_provider_transport_credential_fence(¤t_transport)
|
||||||
.await
|
.await
|
||||||
.map_err(
|
.map_err(
|
||||||
|err| provider_transport::LocalOAuthRefreshError::InvalidResponse {
|
|err| provider_transport::LocalOAuthRefreshError::InvalidResponse {
|
||||||
@@ -1569,7 +1549,7 @@ impl AppState {
|
|||||||
message: format!("{err:?}"),
|
message: format!("{err:?}"),
|
||||||
},
|
},
|
||||||
)? {
|
)? {
|
||||||
Some(ciphertext) => Some(ciphertext),
|
Some(fence) => Some(fence),
|
||||||
None if current_transport.key.decrypted_auth_config.is_some() => {
|
None if current_transport.key.decrypted_auth_config.is_some() => {
|
||||||
let Some(reloaded) = self
|
let Some(reloaded) = self
|
||||||
.read_provider_transport_snapshot_uncached(
|
.read_provider_transport_snapshot_uncached(
|
||||||
@@ -1588,7 +1568,6 @@ impl AppState {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
current_transport = reloaded;
|
current_transport = reloaded;
|
||||||
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -1621,7 +1600,6 @@ impl AppState {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
current_transport = reloaded_transport;
|
current_transport = reloaded_transport;
|
||||||
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1684,7 +1662,7 @@ impl AppState {
|
|||||||
.persist_local_oauth_refresh_entry(
|
.persist_local_oauth_refresh_entry(
|
||||||
¤t_transport,
|
¤t_transport,
|
||||||
&refreshed_entry,
|
&refreshed_entry,
|
||||||
expected_auth_config.as_deref(),
|
expected_credential_fence.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1772,6 +1750,16 @@ impl AppState {
|
|||||||
&self,
|
&self,
|
||||||
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||||
) -> Result<Option<String>, GatewayError> {
|
) -> Result<Option<String>, GatewayError> {
|
||||||
|
Ok(self
|
||||||
|
.capture_provider_transport_credential_fence(transport)
|
||||||
|
.await?
|
||||||
|
.map(|fence| fence.encrypted_auth_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn capture_provider_transport_credential_fence(
|
||||||
|
&self,
|
||||||
|
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||||
|
) -> Result<Option<ProviderTransportCredentialFence>, GatewayError> {
|
||||||
let key_id = transport.key.id.trim();
|
let key_id = transport.key.id.trim();
|
||||||
let stored = self
|
let stored = self
|
||||||
.data
|
.data
|
||||||
@@ -1780,11 +1768,51 @@ impl AppState {
|
|||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next();
|
.next();
|
||||||
let Some(ciphertext) = stored.and_then(|key| key.encrypted_auth_config) else {
|
let Some(stored) = stored else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if stored.provider_id != transport.provider.id
|
||||||
|
|| stored.auth_type != transport.key.auth_type
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let provider = self
|
||||||
|
.data
|
||||||
|
.list_provider_catalog_providers_by_ids(std::slice::from_ref(&stored.provider_id))
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.next();
|
||||||
|
let Some(provider) = provider else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if provider.provider_type != transport.provider.provider_type {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored_api_key = match stored.encrypted_api_key.as_deref() {
|
||||||
|
Some(ciphertext) => Some(
|
||||||
|
decrypt_catalog_secret_with_fallbacks(self.data.encryption_key(), ciphertext)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
GatewayError::Internal(
|
||||||
|
"provider api_key could not be verified for runtime fencing"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let transport_api_key = (!transport.key.decrypted_api_key.is_empty())
|
||||||
|
.then_some(transport.key.decrypted_api_key.as_str());
|
||||||
|
if stored_api_key.as_deref() != transport_api_key {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ciphertext) = stored.encrypted_auth_config.as_deref() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let plaintext =
|
let plaintext =
|
||||||
decrypt_catalog_secret_with_fallbacks(self.data.encryption_key(), ciphertext.as_str())
|
decrypt_catalog_secret_with_fallbacks(self.data.encryption_key(), ciphertext)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
GatewayError::Internal(
|
GatewayError::Internal(
|
||||||
"provider auth_config could not be verified for runtime fencing"
|
"provider auth_config could not be verified for runtime fencing"
|
||||||
@@ -1810,7 +1838,15 @@ impl AppState {
|
|||||||
if config != transport_config {
|
if config != transport_config {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(Some(ciphertext))
|
Ok(Some(ProviderTransportCredentialFence {
|
||||||
|
encrypted_auth_config: ciphertext.to_string(),
|
||||||
|
credential: ProviderCatalogKeyOAuthCredentialFence {
|
||||||
|
encrypted_api_key: stored.encrypted_api_key,
|
||||||
|
auth_type: stored.auth_type,
|
||||||
|
provider_id: stored.provider_id,
|
||||||
|
provider_type: provider.provider_type,
|
||||||
|
},
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn mark_provider_catalog_key_oauth_invalid(
|
pub(crate) async fn mark_provider_catalog_key_oauth_invalid(
|
||||||
@@ -1883,18 +1919,23 @@ impl AppState {
|
|||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn mark_provider_catalog_key_oauth_invalid_fenced(
|
pub(crate) async fn mark_provider_transport_oauth_invalid_fenced(
|
||||||
&self,
|
&self,
|
||||||
key_id: &str,
|
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||||
provider_type: &str,
|
|
||||||
invalid_reason: &str,
|
invalid_reason: &str,
|
||||||
expected_encrypted_auth_config: &str,
|
|
||||||
) -> Result<bool, GatewayError> {
|
) -> Result<bool, GatewayError> {
|
||||||
let invalid_reason = invalid_reason.trim();
|
let invalid_reason = invalid_reason.trim();
|
||||||
let expected_encrypted_auth_config = expected_encrypted_auth_config.trim();
|
let key_id = transport.key.id.trim();
|
||||||
if invalid_reason.is_empty() || expected_encrypted_auth_config.is_empty() {
|
let provider_type = transport.provider.provider_type.as_str();
|
||||||
|
if invalid_reason.is_empty() || key_id.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
let Some(expected_credential_fence) = self
|
||||||
|
.capture_provider_transport_credential_fence(transport)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
let Some(mut latest_key) = self
|
let Some(mut latest_key) = self
|
||||||
.data
|
.data
|
||||||
@@ -1906,7 +1947,12 @@ impl AppState {
|
|||||||
else {
|
else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if latest_key.encrypted_auth_config.as_deref() != Some(expected_encrypted_auth_config)
|
if latest_key.encrypted_auth_config.as_deref()
|
||||||
|
!= Some(expected_credential_fence.encrypted_auth_config.as_str())
|
||||||
|
|| latest_key.encrypted_api_key
|
||||||
|
!= expected_credential_fence.credential.encrypted_api_key
|
||||||
|
|| latest_key.auth_type != expected_credential_fence.credential.auth_type
|
||||||
|
|| latest_key.provider_id != expected_credential_fence.credential.provider_id
|
||||||
|| !provider_key_is_oauth_managed(&latest_key, provider_type)
|
|| !provider_key_is_oauth_managed(&latest_key, provider_type)
|
||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -1940,9 +1986,10 @@ impl AppState {
|
|||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
expected_encrypted_auth_config: Some(
|
expected_encrypted_auth_config: Some(
|
||||||
expected_encrypted_auth_config.to_string(),
|
expected_credential_fence.encrypted_auth_config.clone(),
|
||||||
),
|
),
|
||||||
encrypted_auth_config: expected_encrypted_auth_config.to_string(),
|
expected_credential: Some(expected_credential_fence.credential),
|
||||||
|
encrypted_auth_config: expected_credential_fence.encrypted_auth_config,
|
||||||
encrypted_api_key_update: None,
|
encrypted_api_key_update: None,
|
||||||
expires_at_unix_secs_update: None,
|
expires_at_unix_secs_update: None,
|
||||||
oauth_invalid_at_unix_secs: latest_key.oauth_invalid_at_unix_secs,
|
oauth_invalid_at_unix_secs: latest_key.oauth_invalid_at_unix_secs,
|
||||||
@@ -1965,7 +2012,7 @@ impl AppState {
|
|||||||
&self,
|
&self,
|
||||||
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||||
entry: &provider_transport::CachedOAuthEntry,
|
entry: &provider_transport::CachedOAuthEntry,
|
||||||
expected_auth_config: Option<&str>,
|
expected_credential_fence: Option<&ProviderTransportCredentialFence>,
|
||||||
) -> Result<(), GatewayError> {
|
) -> Result<(), GatewayError> {
|
||||||
let key_id = transport.key.id.trim();
|
let key_id = transport.key.id.trim();
|
||||||
if key_id.is_empty() {
|
if key_id.is_empty() {
|
||||||
@@ -1973,6 +2020,19 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if local_oauth_refresh_entry_should_stay_memory_only(transport, entry) {
|
if local_oauth_refresh_entry_should_stay_memory_only(transport, entry) {
|
||||||
|
let expected_credential_fence = expected_credential_fence.ok_or_else(|| {
|
||||||
|
GatewayError::Internal(
|
||||||
|
"memory-only OAuth refresh has no starting credential fence".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let current_credential_fence = self
|
||||||
|
.capture_provider_transport_credential_fence(transport)
|
||||||
|
.await?;
|
||||||
|
if current_credential_fence.as_ref() != Some(expected_credential_fence) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"OAuth credential changed while memory-only refresh was in flight".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
key_id = %key_id,
|
key_id = %key_id,
|
||||||
provider_id = %transport.provider.id,
|
provider_id = %transport.provider.id,
|
||||||
@@ -2034,18 +2094,22 @@ impl AppState {
|
|||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let expected_credential_fence = expected_credential_fence.ok_or_else(|| {
|
||||||
|
GatewayError::Internal(
|
||||||
|
"Agent Identity task registration has no starting credential fence".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let expected_encrypted_auth_config =
|
let expected_encrypted_auth_config =
|
||||||
expected_auth_config.map(str::to_string).ok_or_else(|| {
|
expected_credential_fence.encrypted_auth_config.clone();
|
||||||
GatewayError::Internal(
|
|
||||||
"Agent Identity task registration has no starting auth_config fence"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if latest_key.encrypted_auth_config.as_deref()
|
if latest_key.encrypted_auth_config.as_deref()
|
||||||
!= Some(expected_encrypted_auth_config.as_str())
|
!= Some(expected_encrypted_auth_config.as_str())
|
||||||
|
|| latest_key.encrypted_api_key
|
||||||
|
!= expected_credential_fence.credential.encrypted_api_key
|
||||||
|
|| latest_key.auth_type != expected_credential_fence.credential.auth_type
|
||||||
|
|| latest_key.provider_id != expected_credential_fence.credential.provider_id
|
||||||
{
|
{
|
||||||
return Err(GatewayError::Internal(
|
return Err(GatewayError::Internal(
|
||||||
"Agent Identity auth_config changed while task registration was in flight"
|
"Agent Identity credential changed while task registration was in flight"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -2093,6 +2157,7 @@ impl AppState {
|
|||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
expected_encrypted_auth_config: Some(expected_encrypted_auth_config),
|
expected_encrypted_auth_config: Some(expected_encrypted_auth_config),
|
||||||
|
expected_credential: Some(expected_credential_fence.credential.clone()),
|
||||||
encrypted_auth_config,
|
encrypted_auth_config,
|
||||||
encrypted_api_key_update: None,
|
encrypted_api_key_update: None,
|
||||||
expires_at_unix_secs_update: None,
|
expires_at_unix_secs_update: None,
|
||||||
@@ -2147,17 +2212,13 @@ impl AppState {
|
|||||||
.map(|value| encrypt_python_fernet_plaintext(encryption_key, value.as_str()))
|
.map(|value| encrypt_python_fernet_plaintext(encryption_key, value.as_str()))
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||||
let requires_fenced_persistence = transport
|
let requires_fenced_persistence =
|
||||||
.provider
|
provider_transport::supports_local_oauth_request_auth_resolution(transport);
|
||||||
.provider_type
|
|
||||||
.trim()
|
|
||||||
.eq_ignore_ascii_case("codex")
|
|
||||||
&& transport.key.auth_type.trim().eq_ignore_ascii_case("oauth");
|
|
||||||
if requires_fenced_persistence
|
if requires_fenced_persistence
|
||||||
&& (expected_auth_config.is_none() || encrypted_auth_config.is_none())
|
&& (expected_credential_fence.is_none() || encrypted_auth_config.is_none())
|
||||||
{
|
{
|
||||||
return Err(GatewayError::Internal(
|
return Err(GatewayError::Internal(
|
||||||
"Codex OAuth refresh persistence is missing its auth_config fence".to_string(),
|
"OAuth refresh persistence is missing its credential fence".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2172,7 +2233,13 @@ impl AppState {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let observed_encrypted_auth_config = latest_key.encrypted_auth_config.clone();
|
let observed_credential_matches = expected_credential_fence.is_none_or(|expected| {
|
||||||
|
latest_key.encrypted_auth_config.as_deref()
|
||||||
|
== Some(expected.encrypted_auth_config.as_str())
|
||||||
|
&& latest_key.encrypted_api_key == expected.credential.encrypted_api_key
|
||||||
|
&& latest_key.auth_type == expected.credential.auth_type
|
||||||
|
&& latest_key.provider_id == expected.credential.provider_id
|
||||||
|
});
|
||||||
latest_key.encrypted_api_key = Some(encrypted_api_key.clone());
|
latest_key.encrypted_api_key = Some(encrypted_api_key.clone());
|
||||||
latest_key.encrypted_auth_config = encrypted_auth_config.clone();
|
latest_key.encrypted_auth_config = encrypted_auth_config.clone();
|
||||||
latest_key.expires_at_unix_secs = entry.expires_at_unix_secs;
|
latest_key.expires_at_unix_secs = entry.expires_at_unix_secs;
|
||||||
@@ -2191,17 +2258,20 @@ impl AppState {
|
|||||||
latest_key.status_snapshot =
|
latest_key.status_snapshot =
|
||||||
sync_provider_key_oauth_status_snapshot(current_status_snapshot, &latest_key);
|
sync_provider_key_oauth_status_snapshot(current_status_snapshot, &latest_key);
|
||||||
let used_fenced_persistence =
|
let used_fenced_persistence =
|
||||||
expected_auth_config.is_some() && encrypted_auth_config.is_some();
|
expected_credential_fence.is_some() && encrypted_auth_config.is_some();
|
||||||
let updated = if let (Some(expected_auth_config), Some(encrypted_auth_config)) =
|
let updated = if let (Some(expected_credential_fence), Some(encrypted_auth_config)) =
|
||||||
(expected_auth_config, encrypted_auth_config.as_deref())
|
(expected_credential_fence, encrypted_auth_config.as_deref())
|
||||||
{
|
{
|
||||||
if observed_encrypted_auth_config.as_deref() != Some(expected_auth_config) {
|
if !observed_credential_matches {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
self.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
self.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
expected_encrypted_auth_config: Some(expected_auth_config.to_string()),
|
expected_encrypted_auth_config: Some(
|
||||||
|
expected_credential_fence.encrypted_auth_config.clone(),
|
||||||
|
),
|
||||||
|
expected_credential: Some(expected_credential_fence.credential.clone()),
|
||||||
encrypted_auth_config: encrypted_auth_config.to_string(),
|
encrypted_auth_config: encrypted_auth_config.to_string(),
|
||||||
encrypted_api_key_update: Some(encrypted_api_key.clone()),
|
encrypted_api_key_update: Some(encrypted_api_key.clone()),
|
||||||
expires_at_unix_secs_update: Some(entry.expires_at_unix_secs),
|
expires_at_unix_secs_update: Some(entry.expires_at_unix_secs),
|
||||||
@@ -2250,7 +2320,7 @@ impl AppState {
|
|||||||
};
|
};
|
||||||
if !updated && (requires_fenced_persistence || used_fenced_persistence) {
|
if !updated && (requires_fenced_persistence || used_fenced_persistence) {
|
||||||
return Err(GatewayError::Internal(
|
return Err(GatewayError::Internal(
|
||||||
"Codex OAuth credential changed during refresh persistence".to_string(),
|
"OAuth credential changed during refresh persistence".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let metadata_refresh_token_fingerprint =
|
let metadata_refresh_token_fingerprint =
|
||||||
@@ -2295,13 +2365,13 @@ impl AppState {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.is_some_and(|value| !value.is_empty());
|
.is_some_and(|value| !value.is_empty());
|
||||||
let expected_auth_config = if transport_has_auth_config {
|
let expected_credential_fence = if transport_has_auth_config {
|
||||||
self.capture_provider_transport_auth_config_fence(transport)
|
self.capture_provider_transport_credential_fence(transport)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if transport_has_auth_config && expected_auth_config.is_none() {
|
if transport_has_auth_config && expected_credential_fence.is_none() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2320,10 +2390,13 @@ impl AppState {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if expected_auth_config
|
if expected_credential_fence.as_ref().is_some_and(|expected| {
|
||||||
.as_deref()
|
latest_key.encrypted_auth_config.as_deref()
|
||||||
.is_some_and(|expected| latest_key.encrypted_auth_config.as_deref() != Some(expected))
|
!= Some(expected.encrypted_auth_config.as_str())
|
||||||
{
|
|| latest_key.encrypted_api_key != expected.credential.encrypted_api_key
|
||||||
|
|| latest_key.auth_type != expected.credential.auth_type
|
||||||
|
|| latest_key.provider_id != expected.credential.provider_id
|
||||||
|
}) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2356,13 +2429,18 @@ impl AppState {
|
|||||||
latest_key.status_snapshot =
|
latest_key.status_snapshot =
|
||||||
sync_provider_key_oauth_status_snapshot(current_status_snapshot, &latest_key);
|
sync_provider_key_oauth_status_snapshot(current_status_snapshot, &latest_key);
|
||||||
|
|
||||||
if let Some(expected_auth_config) = expected_auth_config.as_ref() {
|
if let Some(expected_credential_fence) = expected_credential_fence.as_ref() {
|
||||||
updated = self
|
updated = self
|
||||||
.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
||||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||||
key_id: key_id.to_string(),
|
key_id: key_id.to_string(),
|
||||||
expected_encrypted_auth_config: Some(expected_auth_config.clone()),
|
expected_encrypted_auth_config: Some(
|
||||||
encrypted_auth_config: expected_auth_config.clone(),
|
expected_credential_fence.encrypted_auth_config.clone(),
|
||||||
|
),
|
||||||
|
expected_credential: Some(expected_credential_fence.credential.clone()),
|
||||||
|
encrypted_auth_config: expected_credential_fence
|
||||||
|
.encrypted_auth_config
|
||||||
|
.clone(),
|
||||||
encrypted_api_key_update: None,
|
encrypted_api_key_update: None,
|
||||||
expires_at_unix_secs_update: None,
|
expires_at_unix_secs_update: None,
|
||||||
oauth_invalid_at_unix_secs: latest_key.oauth_invalid_at_unix_secs,
|
oauth_invalid_at_unix_secs: latest_key.oauth_invalid_at_unix_secs,
|
||||||
@@ -2407,11 +2485,12 @@ impl AppState {
|
|||||||
// Codex credentials are replaceable under a stable key id. Without a
|
// Codex credentials are replaceable under a stable key id. Without a
|
||||||
// conditional delete, refresh failure handling must retain them after
|
// conditional delete, refresh failure handling must retain them after
|
||||||
// writing the generation-fenced marker.
|
// writing the generation-fenced marker.
|
||||||
let auto_removed = if !transport
|
let auto_removed = if expected_credential_fence.is_none()
|
||||||
.provider
|
&& !transport
|
||||||
.provider_type
|
.provider
|
||||||
.trim()
|
.provider_type
|
||||||
.eq_ignore_ascii_case("codex")
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("codex")
|
||||||
&& admin_provider_quota_pure::provider_auto_remove_banned_keys(
|
&& admin_provider_quota_pure::provider_auto_remove_banned_keys(
|
||||||
transport.provider.config.as_ref(),
|
transport.provider.config.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -2909,6 +2988,60 @@ mod tests {
|
|||||||
(state, repository, encrypted_auth_config)
|
(state, repository, encrypted_auth_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vertex_service_account_state(
|
||||||
|
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>, String) {
|
||||||
|
let mut provider = sample_provider();
|
||||||
|
provider.provider_type = "vertex_ai".to_string();
|
||||||
|
let mut endpoint = sample_endpoint();
|
||||||
|
endpoint.api_format = "gemini:generate_content".to_string();
|
||||||
|
endpoint.api_family = Some("gemini".to_string());
|
||||||
|
endpoint.base_url = "https://aiplatform.googleapis.com".to_string();
|
||||||
|
let auth_config = json!({
|
||||||
|
"client_email": "svc@example.iam.gserviceaccount.com",
|
||||||
|
"private_key": "TEST-PRIVATE-KEY",
|
||||||
|
"project_id": "demo-project"
|
||||||
|
});
|
||||||
|
let encrypted_auth_config =
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, &auth_config.to_string())
|
||||||
|
.expect("Vertex auth config should encrypt");
|
||||||
|
let encrypted_api_key =
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
||||||
|
.expect("Vertex placeholder should encrypt");
|
||||||
|
let key = StoredProviderCatalogKey::new(
|
||||||
|
"key-1".to_string(),
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"Vertex service account".to_string(),
|
||||||
|
"service_account".to_string(),
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("Vertex key should build")
|
||||||
|
.with_transport_fields(
|
||||||
|
Some(json!(["gemini:generate_content"])),
|
||||||
|
encrypted_api_key,
|
||||||
|
Some(encrypted_auth_config.clone()),
|
||||||
|
None,
|
||||||
|
Some(json!({"gemini:generate_content": 1})),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("Vertex key transport should build");
|
||||||
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![endpoint],
|
||||||
|
vec![key],
|
||||||
|
));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository.clone())
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
);
|
||||||
|
(state, repository, encrypted_auth_config)
|
||||||
|
}
|
||||||
|
|
||||||
fn state_with_global_format_conversion(enabled: bool) -> AppState {
|
fn state_with_global_format_conversion(enabled: bool) -> AppState {
|
||||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
vec![sample_provider()],
|
vec![sample_provider()],
|
||||||
@@ -3769,6 +3902,65 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_only_vertex_refresh_rejects_replaced_credential_generation() {
|
||||||
|
let (state, repository, initial_encrypted_auth_config) = vertex_service_account_state();
|
||||||
|
let transport = state
|
||||||
|
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
||||||
|
.await
|
||||||
|
.expect("Vertex transport should load")
|
||||||
|
.expect("Vertex transport should exist");
|
||||||
|
let expected_credential_fence = state
|
||||||
|
.capture_provider_transport_credential_fence(&transport)
|
||||||
|
.await
|
||||||
|
.expect("Vertex fence should load")
|
||||||
|
.expect("Vertex fence should match");
|
||||||
|
let entry = crate::provider_transport::CachedOAuthEntry {
|
||||||
|
provider_type: "vertex_ai".to_string(),
|
||||||
|
auth_header_name: "authorization".to_string(),
|
||||||
|
auth_header_value: "Bearer memory-only-token".to_string(),
|
||||||
|
expires_at_unix_secs: Some(4_102_444_800),
|
||||||
|
metadata: None,
|
||||||
|
source_fingerprint: Some("service-account-generation".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.persist_local_oauth_refresh_entry(&transport, &entry, Some(&expected_credential_fence))
|
||||||
|
.await
|
||||||
|
.expect("unchanged Vertex credential should accept memory-only token");
|
||||||
|
let unchanged = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("Vertex key should load")
|
||||||
|
.pop()
|
||||||
|
.expect("Vertex key should exist");
|
||||||
|
assert_eq!(
|
||||||
|
unchanged.encrypted_auth_config.as_deref(),
|
||||||
|
Some(initial_encrypted_auth_config.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut replacement = unchanged;
|
||||||
|
replacement.encrypted_api_key = Some(
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "admin-replacement")
|
||||||
|
.expect("replacement credential should encrypt"),
|
||||||
|
);
|
||||||
|
repository
|
||||||
|
.update_key(&replacement)
|
||||||
|
.await
|
||||||
|
.expect("replacement credential should persist");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.persist_local_oauth_refresh_entry(
|
||||||
|
&transport,
|
||||||
|
&entry,
|
||||||
|
Some(&expected_credential_fence),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_refresh_persistence_discards_provisional_auth_and_cache_entry() {
|
fn failed_refresh_persistence_discards_provisional_auth_and_cache_entry() {
|
||||||
let mut resolution = Some(crate::provider_transport::LocalOAuthResolution {
|
let mut resolution = Some(crate::provider_transport::LocalOAuthResolution {
|
||||||
@@ -3789,6 +3981,7 @@ mod tests {
|
|||||||
refresh_in_flight: false,
|
refresh_in_flight: false,
|
||||||
reused_refresh: false,
|
reused_refresh: false,
|
||||||
distributed_lease: None,
|
distributed_lease: None,
|
||||||
|
local_refresh_guard: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
super::discard_failed_local_oauth_refresh_resolution(&mut resolution);
|
super::discard_failed_local_oauth_refresh_resolution(&mut resolution);
|
||||||
@@ -3860,13 +4053,18 @@ mod tests {
|
|||||||
"email": "before@example.com",
|
"email": "before@example.com",
|
||||||
"expires_at": 4102444800_u64
|
"expires_at": 4102444800_u64
|
||||||
});
|
});
|
||||||
let (state, repository, expected_auth_config) =
|
let (state, repository, _expected_auth_config) =
|
||||||
codex_oauth_state(&initial_config, "access-old");
|
codex_oauth_state(&initial_config, "access-old");
|
||||||
let transport = state
|
let transport = state
|
||||||
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
||||||
.await
|
.await
|
||||||
.expect("transport should load")
|
.expect("transport should load")
|
||||||
.expect("transport should exist");
|
.expect("transport should exist");
|
||||||
|
let expected_credential_fence = state
|
||||||
|
.capture_provider_transport_credential_fence(&transport)
|
||||||
|
.await
|
||||||
|
.expect("credential fence should load")
|
||||||
|
.expect("credential fence should match the initial transport");
|
||||||
|
|
||||||
let replacement_config = json!({
|
let replacement_config = json!({
|
||||||
"provider_type": "codex",
|
"provider_type": "codex",
|
||||||
@@ -3914,7 +4112,7 @@ mod tests {
|
|||||||
.persist_local_oauth_refresh_entry(
|
.persist_local_oauth_refresh_entry(
|
||||||
&transport,
|
&transport,
|
||||||
&refreshed_entry,
|
&refreshed_entry,
|
||||||
Some(expected_auth_config.as_str()),
|
Some(&expected_credential_fence),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_err());
|
.is_err());
|
||||||
@@ -3935,4 +4133,108 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(stored.expires_at_unix_secs, None);
|
assert_eq!(stored.expires_at_unix_secs, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_refresh_failure_does_not_mark_access_token_only_replacement() {
|
||||||
|
let initial_config = json!({
|
||||||
|
"provider_type": "codex",
|
||||||
|
"refresh_token": "refresh-stable",
|
||||||
|
"expires_at": 4102444800_u64
|
||||||
|
});
|
||||||
|
let (state, repository, _) = codex_oauth_state(&initial_config, "access-old");
|
||||||
|
let stale_transport = state
|
||||||
|
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
||||||
|
.await
|
||||||
|
.expect("transport should load")
|
||||||
|
.expect("transport should exist");
|
||||||
|
|
||||||
|
let replacement_api_key =
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "access-admin")
|
||||||
|
.expect("replacement api key should encrypt");
|
||||||
|
let mut replaced = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key should load")
|
||||||
|
.pop()
|
||||||
|
.expect("key should exist");
|
||||||
|
replaced.encrypted_api_key = Some(replacement_api_key.clone());
|
||||||
|
repository
|
||||||
|
.update_key(&replaced)
|
||||||
|
.await
|
||||||
|
.expect("access token replacement should persist");
|
||||||
|
|
||||||
|
assert!(!state
|
||||||
|
.persist_local_oauth_refresh_failure_state(
|
||||||
|
&stale_transport,
|
||||||
|
401,
|
||||||
|
r#"{"error":"invalid_token"}"#,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stale failure should be ignored"));
|
||||||
|
|
||||||
|
let stored = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key should reload")
|
||||||
|
.pop()
|
||||||
|
.expect("replacement should remain");
|
||||||
|
assert_eq!(
|
||||||
|
stored.encrypted_api_key.as_deref(),
|
||||||
|
Some(replacement_api_key.as_str())
|
||||||
|
);
|
||||||
|
assert!(stored.oauth_invalid_at_unix_secs.is_none());
|
||||||
|
assert!(stored.oauth_invalid_reason.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_request_invalidation_does_not_mark_access_token_only_replacement() {
|
||||||
|
let initial_config = json!({
|
||||||
|
"provider_type": "codex",
|
||||||
|
"refresh_token": "refresh-stable",
|
||||||
|
"expires_at": 4102444800_u64
|
||||||
|
});
|
||||||
|
let (state, repository, _) = codex_oauth_state(&initial_config, "access-old");
|
||||||
|
let stale_transport = state
|
||||||
|
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
||||||
|
.await
|
||||||
|
.expect("transport should load")
|
||||||
|
.expect("transport should exist");
|
||||||
|
|
||||||
|
let replacement_api_key =
|
||||||
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "access-admin")
|
||||||
|
.expect("replacement api key should encrypt");
|
||||||
|
let mut replaced = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key should load")
|
||||||
|
.pop()
|
||||||
|
.expect("key should exist");
|
||||||
|
replaced.encrypted_api_key = Some(replacement_api_key.clone());
|
||||||
|
repository
|
||||||
|
.update_key(&replaced)
|
||||||
|
.await
|
||||||
|
.expect("access token replacement should persist");
|
||||||
|
|
||||||
|
assert!(!state
|
||||||
|
.mark_provider_transport_oauth_invalid_fenced(
|
||||||
|
&stale_transport,
|
||||||
|
"[OAUTH_EXPIRED] stale request",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stale invalidation should be ignored"));
|
||||||
|
|
||||||
|
let stored = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key should reload")
|
||||||
|
.pop()
|
||||||
|
.expect("replacement should remain");
|
||||||
|
assert_eq!(
|
||||||
|
stored.encrypted_api_key.as_deref(),
|
||||||
|
Some(replacement_api_key.as_str())
|
||||||
|
);
|
||||||
|
assert!(stored.oauth_invalid_at_unix_secs.is_none());
|
||||||
|
assert!(stored.oauth_invalid_reason.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,7 +511,13 @@ async fn assert_ai_route_locally_denied_after_execution_runtime_miss_with_reques
|
|||||||
None
|
None
|
||||||
);
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
if request_path.trim_end_matches('/') == "/v1/messages" {
|
||||||
|
assert_eq!(payload["type"], "error");
|
||||||
|
assert_eq!(payload["error"]["type"], "overloaded_error");
|
||||||
|
} else {
|
||||||
|
assert!(payload.get("type").is_none());
|
||||||
|
assert_eq!(payload["error"]["type"], "http_error");
|
||||||
|
}
|
||||||
assert_eq!(payload["error"]["message"], expected_message);
|
assert_eq!(payload["error"]["message"], expected_message);
|
||||||
assert_eq!(*control_execute_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*control_execute_hits.lock().expect("mutex should lock"), 0);
|
||||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|||||||
@@ -992,6 +992,7 @@ async fn gateway_executes_kiro_claude_cli_sync_upstream_stream_via_local_finaliz
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-kiro-cli-finalize-local",
|
"Bearer sk-client-kiro-cli-finalize-local",
|
||||||
|
|||||||
@@ -1326,6 +1326,7 @@ async fn gateway_executes_claude_cli_sync_upstream_stream_via_local_finalize_res
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-stream-sync-local",
|
"Bearer sk-client-claude-cli-stream-sync-local",
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ async fn gateway_executes_kiro_claude_cli_stream_via_local_provider_catalog_cand
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-kiro-cli-local-stream",
|
"Bearer sk-client-kiro-cli-local-stream",
|
||||||
@@ -927,6 +928,9 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
|
|||||||
b"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n"
|
b"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n"
|
||||||
));
|
));
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
yield Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(
|
||||||
|
b"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n"
|
||||||
|
));
|
||||||
yield Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(
|
yield Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(
|
||||||
b"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n"
|
b"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n"
|
||||||
));
|
));
|
||||||
@@ -980,6 +984,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
|
|||||||
let mut response = reqwest::Client::new()
|
let mut response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-local",
|
"Bearer sk-client-claude-cli-local",
|
||||||
@@ -1004,7 +1009,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.text().await.expect("remaining body should read"),
|
response.text().await.expect("remaining body should read"),
|
||||||
""
|
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
@@ -1438,6 +1443,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
|
|||||||
let frames = concat!(
|
let frames = concat!(
|
||||||
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
|
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
|
||||||
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
|
||||||
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n",
|
||||||
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
|
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
|
||||||
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
|
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
|
||||||
);
|
);
|
||||||
@@ -1490,6 +1496,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-code-cli-local",
|
"Bearer sk-client-claude-code-cli-local",
|
||||||
@@ -1522,7 +1529,10 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
|
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
|
||||||
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
|
concat!(
|
||||||
|
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
|
||||||
|
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
@@ -1551,14 +1561,17 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.anthropic_beta,
|
seen_execution_runtime_request.anthropic_beta,
|
||||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,custom-beta"
|
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11,context-1m-2025-08-07,custom-beta"
|
||||||
);
|
);
|
||||||
assert_eq!(seen_execution_runtime_request.x_app, "cli");
|
assert_eq!(seen_execution_runtime_request.x_app, "cli");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.x_stainless_helper_method,
|
seen_execution_runtime_request.x_stainless_helper_method,
|
||||||
"stream"
|
"stream"
|
||||||
);
|
);
|
||||||
assert_eq!(seen_execution_runtime_request.user_agent, "Claude-Code/9.9");
|
assert_eq!(
|
||||||
|
seen_execution_runtime_request.user_agent,
|
||||||
|
"claude-cli/2.1.161 (external, cli)"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.endpoint_tag,
|
seen_execution_runtime_request.endpoint_tag,
|
||||||
"claude-code-cli-local"
|
"claude-code-cli-local"
|
||||||
@@ -1921,6 +1934,7 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_
|
|||||||
let frames = concat!(
|
let frames = concat!(
|
||||||
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
|
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
|
||||||
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\"}\\n\\n\"}}\n",
|
||||||
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: message_stop\\ndata: {\\\"type\\\":\\\"message_stop\\\"}\\n\\n\"}}\n",
|
||||||
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
|
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":31,\"ttfb_ms\":11,\"upstream_bytes\":37}}}\n",
|
||||||
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
|
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
|
||||||
);
|
);
|
||||||
@@ -1985,7 +1999,10 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
|
strip_sse_keepalive_comments(&response.text().await.expect("body should read")),
|
||||||
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
|
concat!(
|
||||||
|
"event: message_start\ndata: {\"type\":\"message_start\"}\n\n",
|
||||||
|
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let seen_execution_runtime_request = seen_execution_runtime
|
let seen_execution_runtime_request = seen_execution_runtime
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-code-cli-local",
|
"Bearer sk-client-claude-code-cli-local",
|
||||||
@@ -535,15 +536,18 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.anthropic_beta,
|
seen_execution_runtime_request.anthropic_beta,
|
||||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,custom-beta"
|
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11,context-1m-2025-08-07,custom-beta"
|
||||||
);
|
);
|
||||||
assert_eq!(seen_execution_runtime_request.x_app, "cli");
|
assert_eq!(seen_execution_runtime_request.x_app, "cli");
|
||||||
assert_eq!(seen_execution_runtime_request.x_stainless_helper_method, "");
|
assert_eq!(seen_execution_runtime_request.x_stainless_helper_method, "");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.x_stainless_package_version,
|
seen_execution_runtime_request.x_stainless_package_version,
|
||||||
"1.0.5"
|
"0.94.0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
seen_execution_runtime_request.user_agent,
|
||||||
|
"claude-cli/2.1.161 (external, cli)"
|
||||||
);
|
);
|
||||||
assert_eq!(seen_execution_runtime_request.user_agent, "Claude-Code/9.9");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.endpoint_tag,
|
seen_execution_runtime_request.endpoint_tag,
|
||||||
"claude-code-cli-local"
|
"claude-code-cli-local"
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-kiro-cli-local-sync",
|
"Bearer sk-client-kiro-cli-local-sync",
|
||||||
@@ -1153,6 +1154,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-kiro-cli-local-refresh",
|
"Bearer sk-client-kiro-cli-local-refresh",
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
trace_id: String,
|
trace_id: String,
|
||||||
url: String,
|
url: String,
|
||||||
model: String,
|
model: String,
|
||||||
|
stream: Option<bool>,
|
||||||
auth_header_value: String,
|
auth_header_value: String,
|
||||||
|
accept: String,
|
||||||
anthropic_version: String,
|
anthropic_version: String,
|
||||||
anthropic_beta: String,
|
anthropic_beta: String,
|
||||||
endpoint_tag: String,
|
endpoint_tag: String,
|
||||||
@@ -270,6 +272,12 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
||||||
let payload: serde_json::Value = serde_json::from_slice(&raw_body)
|
let payload: serde_json::Value = serde_json::from_slice(&raw_body)
|
||||||
.expect("execution runtime payload should parse");
|
.expect("execution runtime payload should parse");
|
||||||
|
let upstream_url = payload
|
||||||
|
.get("url")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let is_count_tokens = upstream_url.ends_with("/v1/messages/count_tokens");
|
||||||
*seen_execution_runtime_inner
|
*seen_execution_runtime_inner
|
||||||
.lock()
|
.lock()
|
||||||
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
|
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
|
||||||
@@ -279,11 +287,7 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
url: payload
|
url: upstream_url,
|
||||||
.get("url")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
model: payload
|
model: payload
|
||||||
.get("body")
|
.get("body")
|
||||||
.and_then(|value| value.get("json_body"))
|
.and_then(|value| value.get("json_body"))
|
||||||
@@ -291,12 +295,23 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
stream: payload
|
||||||
|
.get("body")
|
||||||
|
.and_then(|value| value.get("json_body"))
|
||||||
|
.and_then(|value| value.get("stream"))
|
||||||
|
.and_then(|value| value.as_bool()),
|
||||||
auth_header_value: payload
|
auth_header_value: payload
|
||||||
.get("headers")
|
.get("headers")
|
||||||
.and_then(|value| value.get("x-api-key"))
|
.and_then(|value| value.get("x-api-key"))
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
accept: payload
|
||||||
|
.get("headers")
|
||||||
|
.and_then(|value| value.get("accept"))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
anthropic_version: payload
|
anthropic_version: payload
|
||||||
.get("headers")
|
.get("headers")
|
||||||
.and_then(|value| value.get("anthropic-version"))
|
.and_then(|value| value.get("anthropic-version"))
|
||||||
@@ -344,29 +359,39 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
});
|
});
|
||||||
Json(json!({
|
if is_count_tokens {
|
||||||
"request_id": "trace-claude-chat-local-123",
|
Json(json!({
|
||||||
"status_code": 200,
|
"request_id": "trace-claude-count-tokens-local-123",
|
||||||
"headers": {
|
"status_code": 200,
|
||||||
"content-type": "application/json"
|
"headers": {"content-type": "application/json"},
|
||||||
},
|
"body": {"json_body": {"input_tokens": 17}},
|
||||||
"body": {
|
"telemetry": {"elapsed_ms": 11}
|
||||||
"json_body": {
|
}))
|
||||||
"id": "msg-local-claude-123",
|
} else {
|
||||||
"type": "message",
|
Json(json!({
|
||||||
"model": "claude-sonnet-4-5-upstream",
|
"request_id": "trace-claude-chat-local-123",
|
||||||
"role": "assistant",
|
"status_code": 200,
|
||||||
"content": [],
|
"headers": {
|
||||||
"usage": {
|
"content-type": "application/json"
|
||||||
"input_tokens": 2,
|
},
|
||||||
"output_tokens": 3
|
"body": {
|
||||||
|
"json_body": {
|
||||||
|
"id": "msg-local-claude-123",
|
||||||
|
"type": "message",
|
||||||
|
"model": "claude-sonnet-4-5-upstream",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 2,
|
||||||
|
"output_tokens": 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"elapsed_ms": 29
|
||||||
}
|
}
|
||||||
},
|
}))
|
||||||
"telemetry": {
|
}
|
||||||
"elapsed_ms": 29
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -484,6 +509,125 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
|||||||
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
||||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
for (case, body, expected_message) in [
|
||||||
|
("missing-body", None, "Request body is required"),
|
||||||
|
("invalid-json", Some("{"), "Invalid JSON body"),
|
||||||
|
(
|
||||||
|
"missing-model",
|
||||||
|
Some(r#"{"messages":[]}"#),
|
||||||
|
"model: Field required",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing-messages",
|
||||||
|
Some(r#"{"model":"claude-sonnet-4-5"}"#),
|
||||||
|
"messages: Field required",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut request = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/messages/count_tokens"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("x-api-key", "sk-client-claude-chat-local")
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header(TRACE_ID_HEADER, format!("trace-claude-count-tokens-{case}"));
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.body(body);
|
||||||
|
}
|
||||||
|
let invalid_response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("invalid count_tokens request should complete locally");
|
||||||
|
assert_eq!(invalid_response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let invalid_json: serde_json::Value = invalid_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("Anthropic error body should parse");
|
||||||
|
assert_eq!(invalid_json["type"], "error");
|
||||||
|
assert_eq!(invalid_json["error"]["type"], "invalid_request_error");
|
||||||
|
assert_eq!(invalid_json["error"]["message"], expected_message);
|
||||||
|
assert_eq!(
|
||||||
|
seen_execution_runtime
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock")
|
||||||
|
.as_ref()
|
||||||
|
.map(|request| request.url.as_str()),
|
||||||
|
Some("https://api.anthropic.example/custom/v1/messages"),
|
||||||
|
"invalid count_tokens request must not reach the execution runtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count_tokens_response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/messages/count_tokens"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("x-api-key", "sk-client-claude-chat-local")
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header(TRACE_ID_HEADER, "trace-claude-count-tokens-local-123")
|
||||||
|
.body(
|
||||||
|
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"stream\":true}",
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("count_tokens request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(count_tokens_response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
count_tokens_response
|
||||||
|
.headers()
|
||||||
|
.get(EXECUTION_PATH_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
|
||||||
|
);
|
||||||
|
let seen_count_tokens = seen_execution_runtime
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock")
|
||||||
|
.clone()
|
||||||
|
.expect("count_tokens execution request should be captured");
|
||||||
|
assert_eq!(
|
||||||
|
seen_count_tokens.url,
|
||||||
|
"https://api.anthropic.example/custom/v1/messages/count_tokens"
|
||||||
|
);
|
||||||
|
assert_eq!(seen_count_tokens.model, "claude-sonnet-4-5-upstream");
|
||||||
|
assert_eq!(seen_count_tokens.stream, None);
|
||||||
|
assert_eq!(seen_count_tokens.accept, "application/json");
|
||||||
|
assert_eq!(
|
||||||
|
seen_count_tokens.auth_header_value,
|
||||||
|
"sk-upstream-claude-chat"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count_tokens_json: serde_json::Value = count_tokens_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("count_tokens response should parse");
|
||||||
|
assert_eq!(
|
||||||
|
count_tokens_json["input_tokens"], 17,
|
||||||
|
"unexpected count_tokens response: {count_tokens_json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder
|
||||||
|
.write_all(
|
||||||
|
br#"{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}"#,
|
||||||
|
)
|
||||||
|
.expect("gzip request body should encode");
|
||||||
|
let gzip_body = encoder.finish().expect("gzip request body should finish");
|
||||||
|
let gzip_count_tokens_response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/messages/count_tokens"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::CONTENT_ENCODING, "gzip")
|
||||||
|
.header("x-api-key", "sk-client-claude-chat-local")
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header(TRACE_ID_HEADER, "trace-claude-count-tokens-gzip-123")
|
||||||
|
.body(gzip_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("gzip count_tokens request should succeed");
|
||||||
|
assert_eq!(gzip_count_tokens_response.status(), StatusCode::OK);
|
||||||
|
let gzip_count_tokens_json: serde_json::Value = gzip_count_tokens_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("gzip count_tokens response should parse");
|
||||||
|
assert_eq!(gzip_count_tokens_json["input_tokens"], 17);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
@@ -603,7 +747,8 @@ async fn gateway_surfaces_candidate_list_empty_reason_for_claude_chat_runtime_mi
|
|||||||
Some("candidate_list_empty")
|
Some("candidate_list_empty")
|
||||||
);
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["type"], "error");
|
||||||
|
assert_eq!(payload["error"]["type"], "overloaded_error");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["error"]["message"],
|
payload["error"]["message"],
|
||||||
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求"
|
"没有可用提供商支持模型 claude-sonnet-4-5 的同步请求"
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-local",
|
"Bearer sk-client-claude-cli-local",
|
||||||
@@ -730,6 +731,7 @@ async fn gateway_returns_claude_cli_error_for_local_sync_failure_impl() {
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-local-error",
|
"Bearer sk-client-claude-cli-local-error",
|
||||||
@@ -985,6 +987,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
|
|||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.post(format!("{gateway_url}/v1/messages?beta=true"))
|
.post(format!("{gateway_url}/v1/messages?beta=true"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(http::header::USER_AGENT, "Claude-Code/2.1.0")
|
||||||
.header(
|
.header(
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-openai-local-miss",
|
"Bearer sk-client-claude-cli-openai-local-miss",
|
||||||
@@ -1011,7 +1014,8 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
|
|||||||
Some("all_candidates_skipped")
|
Some("all_candidates_skipped")
|
||||||
);
|
);
|
||||||
let response_json: serde_json::Value = response.json().await.expect("body should parse");
|
let response_json: serde_json::Value = response.json().await.expect("body should parse");
|
||||||
assert_eq!(response_json["error"]["type"], "http_error");
|
assert_eq!(response_json["type"], "error");
|
||||||
|
assert_eq!(response_json["error"]["type"], "overloaded_error");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response_json["error"]["message"],
|
response_json["error"]["message"],
|
||||||
"没有可用提供商支持模型 gpt-5.4 的同步请求"
|
"没有可用提供商支持模型 gpt-5.4 的同步请求"
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ fn sample_decision() -> crate::control::GatewayControlDecision {
|
|||||||
route_class: Some("ai_public".to_string()),
|
route_class: Some("ai_public".to_string()),
|
||||||
route_family: Some("openai".to_string()),
|
route_family: Some("openai".to_string()),
|
||||||
route_kind: Some("chat".to_string()),
|
route_kind: Some("chat".to_string()),
|
||||||
|
client_surface: None,
|
||||||
|
api_operation: None,
|
||||||
|
gateway_credential_carrier: None,
|
||||||
request_auth_channel: None,
|
request_auth_channel: None,
|
||||||
auth_endpoint_signature: None,
|
auth_endpoint_signature: None,
|
||||||
execution_runtime_candidate: true,
|
execution_runtime_candidate: true,
|
||||||
|
|||||||
@@ -1604,7 +1604,7 @@ async fn gateway_refresh_quota_reconciles_unsupported_fixed_provider_endpoints_b
|
|||||||
(
|
(
|
||||||
"provider-vertex-ai-reconcile",
|
"provider-vertex-ai-reconcile",
|
||||||
"vertex_ai",
|
"vertex_ai",
|
||||||
3usize,
|
2usize,
|
||||||
"gemini:generate_content",
|
"gemini:generate_content",
|
||||||
"https://aiplatform.googleapis.com",
|
"https://aiplatform.googleapis.com",
|
||||||
"Vertex AI 暂不支持自动刷新额度",
|
"Vertex AI 暂不支持自动刷新额度",
|
||||||
|
|||||||
@@ -48,6 +48,45 @@ async fn gateway_blocks_blacklisted_ip_before_routing() {
|
|||||||
assert_eq!(payload["error"]["message"], "当前 IP 已被禁止访问");
|
assert_eq!(payload["error"]["message"], "当前 IP 已被禁止访问");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_shapes_blacklist_rejections_for_claude_routes_before_routing() {
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_admin_security_blacklist_for_tests([(
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"blocked".to_string(),
|
||||||
|
)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
|
||||||
|
let request = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(path)
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"model":"claude-sonnet-4","messages":[]}"#))
|
||||||
|
.expect("request should build");
|
||||||
|
|
||||||
|
let response = send_request(gateway.clone(), request).await;
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||||||
|
let payload = response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let payload: serde_json::Value =
|
||||||
|
serde_json::from_slice(&payload).expect("response should be json");
|
||||||
|
assert_eq!(payload["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(payload["error"]["type"], "permission_error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"], "当前 IP 已被禁止访问",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_blocks_forwarded_ip_from_trusted_proxy() {
|
async fn gateway_blocks_forwarded_ip_from_trusted_proxy() {
|
||||||
let gateway = build_router_with_state(
|
let gateway = build_router_with_state(
|
||||||
|
|||||||
@@ -1758,6 +1758,144 @@ async fn gateway_reports_field_path_for_invalid_admin_system_config_import_shape
|
|||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import() {
|
||||||
|
run_admin_system_import_test(
|
||||||
|
"gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import",
|
||||||
|
gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import_impl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gateway_rejects_invalid_anthropic_profiles_during_admin_system_config_import_impl() {
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(build_empty_admin_system_data_state()),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
for config_scope in ["provider", "endpoint"] {
|
||||||
|
let mut payload = sample_system_import_payload();
|
||||||
|
let invalid_config = json!({
|
||||||
|
"anthropic": {"compatibility_profile": "claude_cod_typo"}
|
||||||
|
});
|
||||||
|
if config_scope == "provider" {
|
||||||
|
payload["providers"][0]["config"] = invalid_config;
|
||||||
|
} else {
|
||||||
|
payload["providers"][0]["endpoints"][0]["config"] = invalid_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("invalid Anthropic profile import should complete locally");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body: Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(
|
||||||
|
body["detail"], "无效的 Anthropic compatibility profile",
|
||||||
|
"unexpected {config_scope} validation response: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import() {
|
||||||
|
run_admin_system_import_test(
|
||||||
|
"gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import",
|
||||||
|
gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import_impl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gateway_does_not_restore_retired_vertex_claude_endpoint_from_system_import_impl() {
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
|
||||||
|
StoredPublicGlobalModel,
|
||||||
|
>::new()));
|
||||||
|
let data_state = build_admin_system_data_state_with_repositories(
|
||||||
|
Arc::clone(&provider_catalog_repository),
|
||||||
|
Arc::clone(&global_model_repository),
|
||||||
|
);
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(data_state),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let mut payload = sample_system_import_payload();
|
||||||
|
payload["providers"][0]["name"] = json!("legacy-vertex-backup");
|
||||||
|
payload["providers"][0]["provider_type"] = json!("vertex_ai");
|
||||||
|
payload["providers"][0]["endpoints"] = json!([
|
||||||
|
{
|
||||||
|
"api_format": "gemini:generate_content",
|
||||||
|
"base_url": "https://aiplatform.googleapis.com",
|
||||||
|
"max_retries": 2,
|
||||||
|
"is_active": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_format": "claude:messages",
|
||||||
|
"base_url": "https://aiplatform.googleapis.com",
|
||||||
|
"max_retries": 2,
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
payload["providers"][0]["api_keys"] = json!([]);
|
||||||
|
payload["providers"][0]["models"] = json!([]);
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||||
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("legacy Vertex import should complete");
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let response_body: Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(status, StatusCode::OK, "payload={response_body}");
|
||||||
|
assert_eq!(response_body["stats"]["endpoints"]["created"], json!(1));
|
||||||
|
assert_eq!(response_body["stats"]["endpoints"]["skipped"], json!(1));
|
||||||
|
assert!(response_body["stats"]["errors"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|errors| errors.iter().any(|error| {
|
||||||
|
error
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|error| error.contains("claude:messages"))
|
||||||
|
})));
|
||||||
|
|
||||||
|
let providers = provider_catalog_repository
|
||||||
|
.list_providers(false)
|
||||||
|
.await
|
||||||
|
.expect("providers should load");
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
let endpoints = provider_catalog_repository
|
||||||
|
.list_endpoints_by_provider_ids(std::slice::from_ref(&providers[0].id))
|
||||||
|
.await
|
||||||
|
.expect("endpoints should load");
|
||||||
|
assert_eq!(endpoints.len(), 1, "unexpected endpoints: {endpoints:?}");
|
||||||
|
assert_eq!(endpoints[0].api_format, "gemini:generate_content");
|
||||||
|
assert!(endpoints[0].is_active);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gateway_imports_admin_system_config_with_numeric_string_prices() {
|
fn gateway_imports_admin_system_config_with_numeric_string_prices() {
|
||||||
run_admin_system_import_test(
|
run_admin_system_import_test(
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ async fn gateway_locally_denies_invalid_bearer_api_key_without_hitting_control_o
|
|||||||
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
|
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
|
||||||
);
|
);
|
||||||
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
||||||
|
assert!(payload.get("type").is_none());
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["error"]["type"], "http_error");
|
||||||
assert_eq!(payload["error"]["message"], "无效的API密钥");
|
assert_eq!(payload["error"]["message"], "无效的API密钥");
|
||||||
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
|
||||||
@@ -331,6 +332,57 @@ async fn gateway_locally_denies_invalid_bearer_api_key_without_hitting_control_o
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_claude_routes_use_anthropic_authentication_error_for_invalid_api_key() {
|
||||||
|
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
Some(hash_api_key("sk-other-claude-key")),
|
||||||
|
sample_currently_usable_auth_snapshot("key-claude-other", "user-claude-other"),
|
||||||
|
)]));
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_auth_api_key_data_reader_for_tests(repository),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
for (path, trace_id) in [
|
||||||
|
("/v1/messages", "trace-control-claude-invalid-key-messages"),
|
||||||
|
(
|
||||||
|
"/v1/messages/count_tokens",
|
||||||
|
"trace-control-claude-invalid-key-count-tokens",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = client
|
||||||
|
.post(format!("{gateway_url}{path}"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("x-api-key", "sk-missing-claude-key")
|
||||||
|
.header(TRACE_ID_HEADER, trace_id)
|
||||||
|
.body("{\"model\":\"claude-sonnet-4-5\",\"messages\":[]}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should complete locally");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(EXECUTION_PATH_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED),
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
||||||
|
assert_eq!(payload["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["type"], "authentication_error",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_locally_denies_admin_proxy_without_admin_principal_and_without_hitting_upstream() {
|
async fn gateway_locally_denies_admin_proxy_without_admin_principal_and_without_hitting_upstream() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
@@ -498,7 +550,8 @@ async fn gateway_locally_denies_disallowed_claude_api_format_without_hitting_con
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["type"], "error");
|
||||||
|
assert_eq!(payload["error"]["type"], "permission_error");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["error"]["message"],
|
payload["error"]["message"],
|
||||||
"当前用户、用户组或密钥的访问控制策略不允许访问 claude:messages 格式"
|
"当前用户、用户组或密钥的访问控制策略不允许访问 claude:messages 格式"
|
||||||
@@ -581,7 +634,8 @@ async fn gateway_locally_denies_disallowed_provider_without_hitting_control_or_u
|
|||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
||||||
assert_eq!(payload["error"]["type"], "http_error");
|
assert_eq!(payload["type"], "error");
|
||||||
|
assert_eq!(payload["error"]["type"], "permission_error");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["error"]["message"],
|
payload["error"]["message"],
|
||||||
"当前用户、用户组或密钥的访问控制策略不允许访问 claude 提供商"
|
"当前用户、用户组或密钥的访问控制策略不允许访问 claude 提供商"
|
||||||
@@ -817,3 +871,52 @@ async fn gateway_locally_denies_disallowed_openai_model_without_hitting_control_
|
|||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_locally_denies_disallowed_claude_model_with_anthropic_permission_error() {
|
||||||
|
let mut snapshot =
|
||||||
|
sample_currently_usable_auth_snapshot("key-claude-model-123", "user-claude-model-123");
|
||||||
|
snapshot.api_key_allowed_providers = Some(vec!["claude".to_string()]);
|
||||||
|
snapshot.user_allowed_providers = Some(vec!["claude".to_string()]);
|
||||||
|
snapshot.api_key_allowed_api_formats = Some(vec!["claude:messages".to_string()]);
|
||||||
|
snapshot.user_allowed_api_formats = Some(vec!["claude:messages".to_string()]);
|
||||||
|
snapshot.api_key_allowed_models = Some(vec!["claude-haiku-4-5".to_string()]);
|
||||||
|
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
Some(hash_api_key("sk-claude-model-guard-123")),
|
||||||
|
snapshot,
|
||||||
|
)]));
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway state should build")
|
||||||
|
.with_auth_api_key_data_reader_for_tests(repository),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header("x-api-key", "sk-claude-model-guard-123")
|
||||||
|
.header(TRACE_ID_HEADER, "trace-control-claude-model-guard-1")
|
||||||
|
.body("{\"model\":\"claude-sonnet-4-5\",\"messages\":[]}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should complete locally");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(EXECUTION_PATH_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
|
||||||
|
);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("response json should parse");
|
||||||
|
assert_eq!(payload["type"], "error");
|
||||||
|
assert_eq!(payload["error"]["type"], "permission_error");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"],
|
||||||
|
"当前用户、用户组或密钥的访问控制策略不允许访问模型 claude-sonnet-4-5"
|
||||||
|
);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -990,136 +990,6 @@ async fn gateway_handles_public_gemini_models_without_hitting_fallback_probe() {
|
|||||||
fallback_probe_handle.abort();
|
fallback_probe_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gateway_handles_claude_count_tokens_without_hitting_fallback_probe() {
|
|
||||||
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
|
||||||
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
|
|
||||||
let fallback_probe = Router::new().route(
|
|
||||||
"/{*path}",
|
|
||||||
any(move |_request: Request| {
|
|
||||||
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
|
|
||||||
async move {
|
|
||||||
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
|
|
||||||
(StatusCode::OK, Json(json!({"proxied": true}))).into_response()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
||||||
Some(hash_api_key("sk-claude-count")),
|
|
||||||
unrestricted_models_snapshot("key-claude-count", "user-claude-count"),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
|
|
||||||
let gateway = build_router_with_state(
|
|
||||||
AppState::new()
|
|
||||||
.expect("gateway should build")
|
|
||||||
.with_auth_api_key_data_reader_for_tests(auth_repository),
|
|
||||||
);
|
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(format!("{gateway_url}/v1/messages/count_tokens"))
|
|
||||||
.header("x-api-key", "sk-claude-count")
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
|
||||||
.body(
|
|
||||||
serde_json::to_vec(&json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"system": [{"type": "text", "text": "abcdefghijklmnop"}],
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "abcdefghijkl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": "abcdefgh"},
|
|
||||||
{"type": "tool_use", "name": "ignored", "input": {"city": "SF"}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
.expect("request body should encode"),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
response
|
|
||||||
.headers()
|
|
||||||
.get(EXECUTION_PATH_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some(EXECUTION_PATH_LOCAL_AI_PUBLIC)
|
|
||||||
);
|
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
||||||
assert_eq!(payload["input_tokens"], 17);
|
|
||||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
|
||||||
|
|
||||||
gateway_handle.abort();
|
|
||||||
fallback_probe_handle.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gateway_rejects_invalid_claude_count_tokens_payload_without_hitting_fallback_probe() {
|
|
||||||
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
|
||||||
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
|
|
||||||
let fallback_probe = Router::new().route(
|
|
||||||
"/{*path}",
|
|
||||||
any(move |_request: Request| {
|
|
||||||
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
|
|
||||||
async move {
|
|
||||||
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
|
|
||||||
(StatusCode::OK, Json(json!({"proxied": true}))).into_response()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
||||||
Some(hash_api_key("sk-claude-count-invalid")),
|
|
||||||
unrestricted_models_snapshot("key-claude-count-invalid", "user-claude-count-invalid"),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
|
|
||||||
let gateway = build_router_with_state(
|
|
||||||
AppState::new()
|
|
||||||
.expect("gateway should build")
|
|
||||||
.with_auth_api_key_data_reader_for_tests(auth_repository),
|
|
||||||
);
|
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(format!("{gateway_url}/v1/messages/count_tokens"))
|
|
||||||
.header("x-api-key", "sk-claude-count-invalid")
|
|
||||||
.body(
|
|
||||||
serde_json::to_vec(&json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"messages": [{"role": "system", "content": "bad"}]
|
|
||||||
}))
|
|
||||||
.expect("request body should encode"),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
||||||
assert_eq!(
|
|
||||||
response
|
|
||||||
.headers()
|
|
||||||
.get(EXECUTION_PATH_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some(EXECUTION_PATH_LOCAL_AI_PUBLIC)
|
|
||||||
);
|
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
||||||
assert_eq!(payload["detail"], "Invalid token count payload");
|
|
||||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
|
||||||
|
|
||||||
gateway_handle.abort();
|
|
||||||
fallback_probe_handle.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() {
|
async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() {
|
||||||
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -383,6 +383,86 @@ async fn gateway_rejects_execution_runtime_loop_guarded_ai_request() {
|
|||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_shapes_execution_loop_rejections_for_claude_routes() {
|
||||||
|
let gateway = build_router().expect("gateway should build");
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}{path}"))
|
||||||
|
.header(
|
||||||
|
EXECUTION_RUNTIME_LOOP_GUARD_HEADER,
|
||||||
|
EXECUTION_RUNTIME_LOOP_GUARD_VALUE,
|
||||||
|
)
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(r#"{"model":"claude-sonnet-4","messages":[]}"#)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::LOOP_DETECTED, "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(EXECUTION_PATH_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some(EXECUTION_PATH_LOCAL_EXECUTION_LOOP_DETECTED),
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||||
|
assert_eq!(payload["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(payload["error"]["type"], "api_error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"],
|
||||||
|
"Gateway detected an execution runtime request loop back into the local frontdoor",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_shapes_wrong_method_rejections_for_claude_routes() {
|
||||||
|
let gateway = build_router().expect("gateway should build");
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
for path in ["/v1/messages", "/v1/messages/count_tokens"] {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(format!("{gateway_url}{path}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::ALLOW)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("POST"),
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||||
|
assert_eq!(payload["type"], "error", "path: {path}");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["type"], "invalid_request_error",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["message"], "Method not allowed",
|
||||||
|
"path: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_rejects_execution_runtime_via_guarded_ai_request() {
|
async fn gateway_rejects_execution_runtime_via_guarded_ai_request() {
|
||||||
let gateway = build_router().expect("gateway should build");
|
let gateway = build_router().expect("gateway should build");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user