mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
merge(main): sync latest main into security branch
This commit is contained in:
@@ -455,7 +455,10 @@ fn gemini_part_is_client_semantic(part: &Value) -> bool {
|
||||
return true;
|
||||
}
|
||||
if part.get("thought").and_then(Value::as_bool) == Some(true) {
|
||||
return false;
|
||||
return part
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty());
|
||||
}
|
||||
if part.keys().all(|key| key == "thoughtSignature") {
|
||||
return false;
|
||||
@@ -584,17 +587,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_gate_waits_through_thought_and_commits_on_text() {
|
||||
fn gemini_gate_commits_on_first_nonempty_thought() {
|
||||
let mut gate = StreamCommitGate::new(gemini_policy());
|
||||
let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"checking\"}]}}]}}\n\n";
|
||||
let text = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"answer\"}]}}]}}\n\n";
|
||||
|
||||
assert_eq!(
|
||||
gate.observe_provider_bytes(thought),
|
||||
StreamPrecommitObservation::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
gate.observe_provider_bytes(text),
|
||||
StreamPrecommitObservation::Commit
|
||||
);
|
||||
assert_eq!(gate.state(), StreamCommitState::Committed);
|
||||
@@ -615,7 +613,7 @@ mod tests {
|
||||
#[test]
|
||||
fn gemini_gate_rejects_malformed_function_call_before_commit() {
|
||||
let mut gate = StreamCommitGate::new(gemini_policy());
|
||||
let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"calling\"}]}}]}}\n\n";
|
||||
let thought = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]}}]}}\n\n";
|
||||
let malformed = b"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thoughtSignature\":\"signature\",\"text\":\"\"}]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\",\"finishMessage\":\"Malformed function call: Function call is empty - no input to parse.\"}]}}\n\n";
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -79,6 +79,7 @@ use crate::api::response::{
|
||||
use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
|
||||
use crate::constants::{CONTROL_CANDIDATE_ID_HEADER, CONTROL_REQUEST_ID_HEADER};
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::attempt_cancellation::AttemptCancellationGuard;
|
||||
use crate::execution_runtime::build_direct_execution_frame_stream;
|
||||
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_stream;
|
||||
use crate::execution_runtime::grok::maybe_execute_grok_stream;
|
||||
@@ -151,6 +152,11 @@ use crate::{
|
||||
AppState, GatewayError, GEMINI_FILES_DOWNLOAD_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
};
|
||||
|
||||
/// Settlement labels for a stream attempt whose future is dropped before the
|
||||
/// transport reaches a terminal state.
|
||||
const STREAM_ATTEMPT_CANCELLED_ERROR_TYPE: &str = "local_stream_attempt_cancelled";
|
||||
const STREAM_ATTEMPT_CANCELLED_ERROR_MESSAGE: &str = "Local stream attempt was dropped before terminal finalization, usually because the client disconnected or the request task was cancelled.";
|
||||
|
||||
const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const SSE_KEEPALIVE_BYTES: &[u8] = b": aether-keepalive\n\n";
|
||||
const SSE_CONTROL_FILTER_MAX_BUFFER_BYTES: usize = 1024 * 1024;
|
||||
@@ -3718,17 +3724,30 @@ pub(crate) fn execute_execution_runtime_stream<'a>(
|
||||
report_kind: Option<String>,
|
||||
report_context: Option<serde_json::Value>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Option<Response<Body>>, GatewayError>> + Send + 'a>> {
|
||||
Box::pin(execute_execution_runtime_stream_inner(
|
||||
state,
|
||||
plan,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
report_kind,
|
||||
report_context,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
Box::pin(async move {
|
||||
let mut cancellation_guard = AttemptCancellationGuard::disarmed(
|
||||
state,
|
||||
STREAM_ATTEMPT_CANCELLED_ERROR_TYPE,
|
||||
STREAM_ATTEMPT_CANCELLED_ERROR_MESSAGE,
|
||||
);
|
||||
let result = execute_execution_runtime_stream_inner(
|
||||
state,
|
||||
plan,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
report_kind,
|
||||
report_context,
|
||||
None,
|
||||
None,
|
||||
&mut cancellation_guard,
|
||||
)
|
||||
.await;
|
||||
// The attempt reached its own terminal path, or handed settlement to the
|
||||
// stream finalizer that now lives in the response body.
|
||||
cancellation_guard.disarm();
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -3750,7 +3769,12 @@ pub(crate) fn execute_execution_runtime_stream_with_retry_scope<'a>(
|
||||
Box::pin(async move {
|
||||
let mut retry_scope = AiAttemptRetryScope::Candidate;
|
||||
let mut fallback_response = None;
|
||||
let response = execute_execution_runtime_stream_inner(
|
||||
let mut cancellation_guard = AttemptCancellationGuard::disarmed(
|
||||
state,
|
||||
STREAM_ATTEMPT_CANCELLED_ERROR_TYPE,
|
||||
STREAM_ATTEMPT_CANCELLED_ERROR_MESSAGE,
|
||||
);
|
||||
let result = execute_execution_runtime_stream_inner(
|
||||
state,
|
||||
plan,
|
||||
trace_id,
|
||||
@@ -3760,8 +3784,13 @@ pub(crate) fn execute_execution_runtime_stream_with_retry_scope<'a>(
|
||||
report_context,
|
||||
Some(&mut retry_scope),
|
||||
Some(&mut fallback_response),
|
||||
&mut cancellation_guard,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
// The attempt reached its own terminal path, or handed settlement to the
|
||||
// stream finalizer that now lives in the response body.
|
||||
cancellation_guard.disarm();
|
||||
let response = result?;
|
||||
Ok(match response {
|
||||
Some(response) => AiAttemptExecutionOutcome::Responded(response),
|
||||
None => AiAttemptExecutionOutcome::Retry {
|
||||
@@ -3807,6 +3836,7 @@ async fn maybe_build_stream_transport_error_stop_response(
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // internal function, grouping would add unnecessary indirection
|
||||
async fn execute_execution_runtime_stream_inner(
|
||||
state: &AppState,
|
||||
mut plan: ExecutionPlan,
|
||||
@@ -3817,6 +3847,7 @@ async fn execute_execution_runtime_stream_inner(
|
||||
mut report_context: Option<serde_json::Value>,
|
||||
mut retry_scope_out: Option<&mut AiAttemptRetryScope>,
|
||||
mut retry_fallback_out: Option<&mut Option<Response<Body>>>,
|
||||
cancellation_guard: &mut AttemptCancellationGuard,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let stream_started_at = Instant::now();
|
||||
let mut stage_trace = RequestStageTrace::from_env();
|
||||
@@ -3900,6 +3931,16 @@ async fn execute_execution_runtime_stream_inner(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// From here the attempt owns non-terminal rows, and everything that could
|
||||
// settle them runs inside the downstream request future. Arm the guard so a
|
||||
// client disconnect before the stream finalizer exists still settles them.
|
||||
cancellation_guard.arm(
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
request_candidate_status_snapshot.as_ref(),
|
||||
candidate_started_unix_secs,
|
||||
stream_started_at,
|
||||
);
|
||||
let plan_request_id_for_log = short_request_id(plan.request_id.as_str());
|
||||
let provider_name = plan
|
||||
.provider_name
|
||||
@@ -10764,7 +10805,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_antigravity_function_call_retries_before_stream_commit() {
|
||||
async fn malformed_antigravity_function_call_streams_thought_then_fails_in_band() {
|
||||
let request_id = "req-antigravity-malformed-function-call";
|
||||
let plan = antigravity_gemini_stream_plan(request_id);
|
||||
let provider_catalog = provider_catalog_for_plan(
|
||||
@@ -10843,10 +10884,35 @@ mod tests {
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("malformed Antigravity stream should resolve through failover");
|
||||
.expect("malformed Antigravity stream should return a client stream")
|
||||
.expect("the first reasoning delta should commit the selected candidate");
|
||||
|
||||
assert!(response.is_none());
|
||||
assert_eq!(retry_scope, AiAttemptRetryScope::Candidate);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("response body should read");
|
||||
let body = String::from_utf8(body.to_vec()).expect("response body should be utf8");
|
||||
assert!(
|
||||
body.contains("event: response.reasoning_summary_text.delta\n"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("\"delta\":\"Validating the document.\""),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("event: response.failed\n"), "{body}");
|
||||
assert!(
|
||||
body.contains("\"code\":\"MALFORMED_FUNCTION_CALL\""),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(
|
||||
"\"message\":\"Malformed function call: Function call is empty - no input to parse.\""
|
||||
),
|
||||
"{body}"
|
||||
);
|
||||
assert!(!body.contains("unsupported_finish_reason"), "{body}");
|
||||
assert_eq!(retry_scope, AiAttemptRetryScope::Provider);
|
||||
}
|
||||
|
||||
fn tunnel_proxy_snapshot(base_url: String) -> aether_contracts::ProxySnapshot {
|
||||
|
||||
Reference in New Issue
Block a user