mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
fix(provider): 修复 Windsurf 原生工具桥接
This commit is contained in:
@@ -44,13 +44,14 @@ use super::error::{
|
||||
#[path = "execution_failures.rs"]
|
||||
mod execution_failures;
|
||||
use self::execution_failures::{
|
||||
build_stream_failure_from_execution_error, build_stream_failure_report,
|
||||
build_stream_failure_from_execution_error, build_stream_failure_from_provider_error_body,
|
||||
build_stream_failure_report, handle_prefetch_provider_private_stream_error,
|
||||
handle_prefetch_stream_failure, submit_midstream_stream_failure, StreamFailureReport,
|
||||
};
|
||||
use crate::ai_serving::api::{
|
||||
maybe_bridge_standard_sync_json_to_stream, maybe_build_provider_private_stream_normalizer,
|
||||
maybe_build_stream_response_rewriter, normalize_provider_private_report_context,
|
||||
StreamingStandardTerminalObserver,
|
||||
extract_provider_private_stream_error_body, maybe_bridge_standard_sync_json_to_stream,
|
||||
maybe_build_provider_private_stream_normalizer, maybe_build_stream_response_rewriter,
|
||||
normalize_provider_private_report_context, StreamingStandardTerminalObserver,
|
||||
};
|
||||
use crate::api::response::{
|
||||
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
|
||||
@@ -73,14 +74,15 @@ use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
|
||||
#[cfg(test)]
|
||||
use crate::execution_runtime::remote_compat::post_stream_plan_to_remote_execution_runtime;
|
||||
use crate::execution_runtime::submission::{
|
||||
resolve_core_error_background_report_kind, strip_utf8_bom_and_ws,
|
||||
submit_local_core_error_or_sync_finalize,
|
||||
resolve_core_error_background_report_kind, resolve_local_sync_error_status_code,
|
||||
strip_utf8_bom_and_ws, submit_local_core_error_or_sync_finalize,
|
||||
};
|
||||
use crate::execution_runtime::transport::{
|
||||
execute_stream_plan_via_local_tunnel, record_manual_proxy_request_failure,
|
||||
record_manual_proxy_request_success, record_manual_proxy_stream_error,
|
||||
DirectSyncExecutionRuntime, DirectUpstreamStreamExecution, ExecutionRuntimeTransportError,
|
||||
};
|
||||
use crate::execution_runtime::windsurf::maybe_execute_windsurf_stream;
|
||||
use crate::execution_runtime::{
|
||||
apply_endpoint_response_header_rules, attach_provider_response_headers_to_report_context,
|
||||
local_failover_response_text, resolve_core_stream_direct_finalize_report_kind,
|
||||
@@ -849,6 +851,58 @@ pub(crate) async fn execute_execution_runtime_stream(
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
match maybe_execute_windsurf_stream(state, &plan, report_context.as_ref()).await {
|
||||
Ok(Some(windsurf_stream)) => {
|
||||
return execute_stream_from_frame_stream(
|
||||
state,
|
||||
plan,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
report_kind,
|
||||
windsurf_stream.report_context.or(report_context),
|
||||
candidate_started_unix_secs,
|
||||
stream_started_at,
|
||||
windsurf_stream.frame_stream,
|
||||
provider_pool_in_flight_guard.take(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
info!(
|
||||
event_name = "windsurf_native_execution_unavailable",
|
||||
log_type = "ops",
|
||||
trace_id = %trace_id,
|
||||
request_id = %plan_request_id_for_log,
|
||||
candidate_id = ?plan.candidate_id,
|
||||
provider_name = provider_name.as_str(),
|
||||
endpoint_id = %endpoint_id,
|
||||
key_id = %key_id,
|
||||
model_name = model_name.as_str(),
|
||||
candidate_index = candidate_index.as_str(),
|
||||
error = %err,
|
||||
"gateway native Windsurf stream execution unavailable"
|
||||
);
|
||||
let terminal_unix_secs = current_request_candidate_unix_ms();
|
||||
record_local_request_candidate_status(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: None,
|
||||
error_type: Some("windsurf_native_execution_unavailable".to_string()),
|
||||
error_message: Some(err.to_string()),
|
||||
latency_ms: None,
|
||||
started_at_unix_ms: Some(candidate_started_unix_secs),
|
||||
finished_at_unix_ms: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
match maybe_execute_kiro_web_search_stream(state, &plan, report_context.as_ref()).await {
|
||||
Ok(Some(kiro_web_search)) => {
|
||||
return execute_stream_from_frame_stream(
|
||||
@@ -1197,13 +1251,13 @@ fn encode_terminal_sse_error_event(failure: &StreamFailureReport) -> Result<Byte
|
||||
let payload = failure
|
||||
.to_json_string()
|
||||
.map_err(|err| IoError::other(err.to_string()))?;
|
||||
let mut event = String::from("event: aether.error\n");
|
||||
let mut event = String::new();
|
||||
for line in payload.lines() {
|
||||
event.push_str("data: ");
|
||||
event.push_str(line);
|
||||
event.push('\n');
|
||||
}
|
||||
event.push('\n');
|
||||
event.push_str("\ndata: [DONE]\n\n");
|
||||
Ok(Bytes::from(event))
|
||||
}
|
||||
|
||||
@@ -1693,24 +1747,46 @@ async fn execute_stream_from_frame_stream(
|
||||
|
||||
if !(200..300).contains(&status_code) {
|
||||
let provider_error_body = collect_error_body(&mut lines).await?;
|
||||
let synthetic_body_json =
|
||||
should_synthesize_non_success_stream_error_body(status_code, &provider_error_body)
|
||||
.then(|| build_synthetic_non_success_stream_error_body(status_code, &headers));
|
||||
let (provider_body_json, provider_body_base64) =
|
||||
decode_stream_error_body(&headers, &provider_error_body);
|
||||
let client_status_code = stream_client_error_status_code_for_upstream_status(status_code);
|
||||
let wrapped_binary_body_json = wrap_non_json_binary_stream_error_for_client(
|
||||
plan_kind,
|
||||
&headers,
|
||||
let private_error_body_json = extract_provider_private_stream_error_body(
|
||||
report_context.as_ref(),
|
||||
&provider_error_body,
|
||||
)?;
|
||||
let (client_body_json, client_error_body) =
|
||||
);
|
||||
let provider_private_error_decoded = private_error_body_json.is_some();
|
||||
let synthetic_body_json = (!provider_private_error_decoded
|
||||
&& should_synthesize_non_success_stream_error_body(status_code, &provider_error_body))
|
||||
.then(|| build_synthetic_non_success_stream_error_body(status_code, &headers));
|
||||
let (provider_body_json, provider_body_base64) =
|
||||
if let Some(error_body_json) = private_error_body_json {
|
||||
(Some(error_body_json), None)
|
||||
} else {
|
||||
decode_stream_error_body(&headers, &provider_error_body)
|
||||
};
|
||||
let client_status_code = stream_client_error_status_code_for_upstream_status(status_code);
|
||||
let wrapped_binary_body_json = if provider_private_error_decoded {
|
||||
None
|
||||
} else {
|
||||
wrap_non_json_binary_stream_error_for_client(plan_kind, &headers, &provider_error_body)?
|
||||
};
|
||||
let (client_body_json, client_error_body, payload_client_body_json) =
|
||||
if let Some(body_json) = synthetic_body_json.or(wrapped_binary_body_json) {
|
||||
let body_bytes = serde_json::to_vec(&body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
(Some(body_json), body_bytes)
|
||||
(Some(body_json.clone()), body_bytes, Some(body_json))
|
||||
} else if provider_private_error_decoded {
|
||||
let body_json = provider_body_json.clone().ok_or_else(|| {
|
||||
GatewayError::Internal(
|
||||
"decoded provider private stream error body is missing".to_string(),
|
||||
)
|
||||
})?;
|
||||
let body_bytes = serde_json::to_vec(&body_json)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
(Some(body_json), body_bytes, None)
|
||||
} else {
|
||||
(provider_body_json.clone(), provider_error_body.clone())
|
||||
(
|
||||
provider_body_json.clone(),
|
||||
provider_error_body.clone(),
|
||||
provider_body_json.clone(),
|
||||
)
|
||||
};
|
||||
let error_response_text =
|
||||
local_failover_response_text(client_body_json.as_ref(), &client_error_body, None);
|
||||
@@ -1897,6 +1973,11 @@ async fn execute_stream_from_frame_stream(
|
||||
} else {
|
||||
headers.clone()
|
||||
};
|
||||
if provider_private_error_decoded {
|
||||
client_headers.remove("content-encoding");
|
||||
client_headers.remove("content-length");
|
||||
client_headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
}
|
||||
apply_endpoint_response_header_rules(
|
||||
state,
|
||||
&plan,
|
||||
@@ -1928,7 +2009,7 @@ async fn execute_stream_from_frame_stream(
|
||||
provider_body_json,
|
||||
provider_body_base64,
|
||||
client_headers,
|
||||
client_body_json,
|
||||
payload_client_body_json,
|
||||
None,
|
||||
);
|
||||
record_sync_terminal_usage(state, &plan, payload.report_context.as_ref(), &payload);
|
||||
@@ -2127,6 +2208,30 @@ async fn execute_stream_from_frame_stream(
|
||||
provider_prefetched_body.extend_from_slice(&chunk);
|
||||
prefetched_inspection_body.extend_from_slice(&chunk);
|
||||
|
||||
if let Some(error_body_json) = extract_provider_private_stream_error_body(
|
||||
report_context.as_ref(),
|
||||
&prefetched_inspection_body,
|
||||
) {
|
||||
let error_status_code =
|
||||
resolve_local_sync_error_status_code(status_code, &error_body_json);
|
||||
return handle_prefetch_provider_private_stream_error(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
&plan,
|
||||
report_context,
|
||||
request_id,
|
||||
candidate_id,
|
||||
report_kind,
|
||||
headers,
|
||||
prefetched_telemetry,
|
||||
&provider_prefetched_body,
|
||||
error_status_code,
|
||||
error_body_json,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let inspection = inspect_prefetched_stream_body(
|
||||
&upstream_headers,
|
||||
&prefetched_inspection_body,
|
||||
@@ -2835,6 +2940,11 @@ async fn execute_stream_from_frame_stream(
|
||||
} else {
|
||||
chunk
|
||||
};
|
||||
let provider_private_error_body_json =
|
||||
extract_provider_private_stream_error_body(
|
||||
stream_usage_report_context.as_ref(),
|
||||
&normalized_chunk,
|
||||
);
|
||||
if let (Some(observer), Some(report_context)) = (
|
||||
stream_usage_observer.as_mut(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -2873,6 +2983,18 @@ async fn execute_stream_from_frame_stream(
|
||||
};
|
||||
|
||||
if rewritten_chunk.is_empty() {
|
||||
if let Some(error_body_json) = provider_private_error_body_json {
|
||||
let error_status_code = resolve_local_sync_error_status_code(
|
||||
status_code,
|
||||
&error_body_json,
|
||||
);
|
||||
terminal_failure =
|
||||
Some(build_stream_failure_from_provider_error_body(
|
||||
error_status_code,
|
||||
&error_body_json,
|
||||
));
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2935,6 +3057,15 @@ async fn execute_stream_from_frame_stream(
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
if let Some(error_body_json) = provider_private_error_body_json {
|
||||
let error_status_code =
|
||||
resolve_local_sync_error_status_code(status_code, &error_body_json);
|
||||
terminal_failure = Some(build_stream_failure_from_provider_error_body(
|
||||
error_status_code,
|
||||
&error_body_json,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
StreamFramePayload::Telemetry {
|
||||
telemetry: frame_telemetry,
|
||||
@@ -2992,6 +3123,11 @@ async fn execute_stream_from_frame_stream(
|
||||
if let Some(normalizer) = private_stream_normalizer.as_mut() {
|
||||
match normalizer.finish() {
|
||||
Ok(normalized_chunk) if !normalized_chunk.is_empty() => {
|
||||
let provider_private_error_body_json =
|
||||
extract_provider_private_stream_error_body(
|
||||
stream_usage_report_context.as_ref(),
|
||||
&normalized_chunk,
|
||||
);
|
||||
if let (Some(observer), Some(report_context)) = (
|
||||
stream_usage_observer.as_mut(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -3065,6 +3201,16 @@ async fn execute_stream_from_frame_stream(
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(error_body_json) = provider_private_error_body_json {
|
||||
let error_status_code =
|
||||
resolve_local_sync_error_status_code(status_code, &error_body_json);
|
||||
terminal_failure.get_or_insert_with(|| {
|
||||
build_stream_failure_from_provider_error_body(
|
||||
error_status_code,
|
||||
&error_body_json,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
@@ -3478,6 +3624,7 @@ mod tests {
|
||||
use axum::extract::Request;
|
||||
use axum::routing::any;
|
||||
use axum::{http::header, http::HeaderValue, Router};
|
||||
use base64::Engine as _;
|
||||
use futures_util::StreamExt as _;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::{mpsc, watch, Notify};
|
||||
@@ -3528,6 +3675,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_json_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(5 + payload.len());
|
||||
out.push(flags);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_stream_terminal_summary_prefers_more_complete_observed_usage() {
|
||||
let mut runtime_usage = StandardizedUsage::new();
|
||||
@@ -4412,6 +4567,268 @@ mod tests {
|
||||
assert!(text.contains("\"type\":\"image_stream_total_timeout\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_stream_from_frame_stream_treats_windsurf_connect_trailer_error_as_failure() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = AppState::new()
|
||||
.expect("app state should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&usage_repository),
|
||||
),
|
||||
)
|
||||
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..UsageRuntimeConfig::default()
|
||||
});
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-windsurf-connect-error".into(),
|
||||
candidate_id: Some("cand-windsurf-connect-error".into()),
|
||||
provider_name: Some("windsurf".into()),
|
||||
provider_id: "provider-windsurf".into(),
|
||||
endpoint_id: "endpoint-windsurf-chat".into(),
|
||||
key_id: "key-windsurf".into(),
|
||||
method: "POST".into(),
|
||||
url: "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage?beta=true".into(),
|
||||
headers: BTreeMap::from([
|
||||
("content-type".into(), "application/connect+json".into()),
|
||||
("accept".into(), "application/connect+json".into()),
|
||||
]),
|
||||
content_type: Some("application/connect+json".into()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"messages": [],
|
||||
"stream": true
|
||||
})),
|
||||
stream: true,
|
||||
client_api_format: "claude:messages".into(),
|
||||
provider_api_format: "openai:chat".into(),
|
||||
model_name: Some("claude-sonnet-4".into()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let trailer_error = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"an internal error occurred"}}"#,
|
||||
);
|
||||
let trailer_error_b64 = base64::engine::general_purpose::STANDARD.encode(trailer_error);
|
||||
let frame = format!(
|
||||
"{{\"type\":\"data\",\"payload\":{{\"kind\":\"data\",\"chunk_b64\":\"{trailer_error_b64}\"}}}}\n"
|
||||
);
|
||||
let frame_stream = stream! {
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from_static(
|
||||
b"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"application/connect+json\"}}}\n",
|
||||
));
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from(frame));
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from_static(
|
||||
b"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n",
|
||||
));
|
||||
}
|
||||
.boxed();
|
||||
|
||||
let response = execute_stream_from_frame_stream(
|
||||
&state,
|
||||
plan,
|
||||
"trace-windsurf-connect-error",
|
||||
&test_decision(),
|
||||
"claude_chat_stream",
|
||||
Some("claude_chat_stream_success".to_string()),
|
||||
Some(json!({
|
||||
"request_id": "req-windsurf-connect-error",
|
||||
"candidate_id": "cand-windsurf-connect-error",
|
||||
"candidate_index": 0,
|
||||
"retry_index": 0,
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "claude:messages",
|
||||
"needs_conversion": true,
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"local_failover_policy": {
|
||||
"stop_status_codes": [429]
|
||||
}
|
||||
})),
|
||||
crate::clock::current_unix_ms(),
|
||||
Instant::now(),
|
||||
frame_stream,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("execution should succeed")
|
||||
.expect("execution should return a client response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("response body should read");
|
||||
let body_json: Value =
|
||||
serde_json::from_slice(&body).expect("response body should decode as json");
|
||||
assert_eq!(status.as_u16(), 429);
|
||||
assert_eq!(body_json["type"], json!("error"));
|
||||
assert_eq!(body_json["error"]["type"], json!("rate_limit_error"));
|
||||
assert_eq!(body_json["error"]["code"], json!("resource_exhausted"));
|
||||
assert_eq!(
|
||||
body_json["error"]["message"],
|
||||
json!("an internal error occurred")
|
||||
);
|
||||
|
||||
let candidates = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let candidates = request_candidate_repository
|
||||
.list_by_request_id("req-windsurf-connect-error")
|
||||
.await
|
||||
.expect("request candidates should read");
|
||||
if candidates
|
||||
.first()
|
||||
.is_some_and(|candidate| candidate.status == RequestCandidateStatus::Failed)
|
||||
{
|
||||
break candidates;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("candidate should be marked failed");
|
||||
assert_eq!(candidates[0].status_code, Some(429));
|
||||
assert_eq!(
|
||||
candidates[0].error_type.as_deref(),
|
||||
Some("resource_exhausted")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_stream_from_frame_stream_decodes_non_success_windsurf_connect_error_body() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = AppState::new()
|
||||
.expect("app state should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&usage_repository),
|
||||
),
|
||||
)
|
||||
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..UsageRuntimeConfig::default()
|
||||
});
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-windsurf-connect-429".into(),
|
||||
candidate_id: Some("cand-windsurf-connect-429".into()),
|
||||
provider_name: Some("windsurf".into()),
|
||||
provider_id: "provider-windsurf".into(),
|
||||
endpoint_id: "endpoint-windsurf-chat".into(),
|
||||
key_id: "key-windsurf".into(),
|
||||
method: "POST".into(),
|
||||
url: "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage?beta=true".into(),
|
||||
headers: BTreeMap::from([
|
||||
("content-type".into(), "application/connect+json".into()),
|
||||
("accept".into(), "application/connect+json".into()),
|
||||
]),
|
||||
content_type: Some("application/connect+json".into()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"messages": [],
|
||||
"stream": true
|
||||
})),
|
||||
stream: true,
|
||||
client_api_format: "claude:messages".into(),
|
||||
provider_api_format: "openai:chat".into(),
|
||||
model_name: Some("claude-sonnet-4".into()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let connect_error = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
let connect_error_b64 = base64::engine::general_purpose::STANDARD.encode(connect_error);
|
||||
let frame_stream = stream! {
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from_static(
|
||||
b"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":429,\"headers\":{\"content-type\":\"application/connect+json\"}}}\n",
|
||||
));
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from(format!(
|
||||
"{{\"type\":\"data\",\"payload\":{{\"kind\":\"data\",\"chunk_b64\":\"{connect_error_b64}\"}}}}\n"
|
||||
)));
|
||||
yield Ok::<Bytes, std::io::Error>(Bytes::from_static(
|
||||
b"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n",
|
||||
));
|
||||
}
|
||||
.boxed();
|
||||
|
||||
let response = execute_stream_from_frame_stream(
|
||||
&state,
|
||||
plan,
|
||||
"trace-windsurf-connect-429",
|
||||
&test_decision(),
|
||||
"claude_chat_stream",
|
||||
Some("claude_chat_stream_success".to_string()),
|
||||
Some(json!({
|
||||
"request_id": "req-windsurf-connect-429",
|
||||
"candidate_id": "cand-windsurf-connect-429",
|
||||
"candidate_index": 0,
|
||||
"retry_index": 0,
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "claude:messages",
|
||||
"needs_conversion": true,
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"local_failover_policy": {
|
||||
"stop_status_codes": [429]
|
||||
}
|
||||
})),
|
||||
crate::clock::current_unix_ms(),
|
||||
Instant::now(),
|
||||
frame_stream,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("execution should succeed")
|
||||
.expect("execution should return a client response");
|
||||
|
||||
assert_eq!(response.status().as_u16(), 429);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("response body should read");
|
||||
let body_json: Value =
|
||||
serde_json::from_slice(&body).expect("response body should decode as json");
|
||||
assert_eq!(body_json["type"], json!("error"));
|
||||
assert_eq!(body_json["error"]["type"], json!("rate_limit_error"));
|
||||
assert_eq!(body_json["error"]["code"], json!("resource_exhausted"));
|
||||
assert_eq!(body_json["error"]["message"], json!("quota exhausted"));
|
||||
|
||||
let record = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Some(usage) = usage_repository
|
||||
.find_by_request_id("req-windsurf-connect-429")
|
||||
.await
|
||||
.expect("usage should read")
|
||||
.filter(|usage| usage.status == "failed")
|
||||
{
|
||||
break usage;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("usage should be written");
|
||||
assert_eq!(record.status_code, Some(429));
|
||||
assert_eq!(
|
||||
record
|
||||
.response_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("error"))
|
||||
.and_then(|error| error.get("code")),
|
||||
Some(&json!("resource_exhausted"))
|
||||
);
|
||||
assert!(record.response_body_ref.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_stream_from_frame_stream_stops_upstream_when_client_drops_body() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
@@ -5560,8 +5977,9 @@ mod tests {
|
||||
|
||||
let body = body_task.await.expect("body task should complete");
|
||||
assert!(body.contains("data: hello\n\n"));
|
||||
assert!(body.contains("event: aether.error\n"));
|
||||
assert!(body.contains("data: {\"error\":"));
|
||||
assert!(body.contains(original_error));
|
||||
assert!(body.contains("data: [DONE]\n\n"));
|
||||
assert!(
|
||||
!body.contains("unexpected EOF during chunk size line"),
|
||||
"same-format SSE path should surface the original terminal error event"
|
||||
|
||||
@@ -126,6 +126,54 @@ pub(super) fn build_stream_failure_from_execution_error(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_stream_failure_from_provider_error_body(
|
||||
status_code: u16,
|
||||
body_json: &Value,
|
||||
) -> StreamFailureReport {
|
||||
let body_object = body_json.as_object();
|
||||
let error_object = body_object
|
||||
.and_then(|object| object.get("error"))
|
||||
.and_then(Value::as_object);
|
||||
let error_type =
|
||||
first_non_empty_error_text(error_object, body_object, &["type", "code", "status"])
|
||||
.unwrap_or_else(|| "upstream_error".to_string());
|
||||
let error_message = first_non_empty_error_text(
|
||||
error_object,
|
||||
body_object,
|
||||
&["message", "detail", "reason", "status", "type", "code"],
|
||||
)
|
||||
.unwrap_or_else(|| format!("upstream stream returned error status {status_code}"));
|
||||
|
||||
StreamFailureReport {
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
extra_error_fields: Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_non_empty_error_text(
|
||||
error_object: Option<&Map<String, Value>>,
|
||||
body_object: Option<&Map<String, Value>>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
for object in [error_object, body_object].into_iter().flatten() {
|
||||
for key in keys {
|
||||
let Some(value) = object.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
Value::Number(number) => return Some(number.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_stream_failure_sync_payload(
|
||||
trace_id: &str,
|
||||
report_kind: String,
|
||||
@@ -296,6 +344,49 @@ async fn record_stream_sync_failure(
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // internal helper for prefetch error handling
|
||||
pub(super) async fn handle_prefetch_provider_private_stream_error(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<Value>,
|
||||
request_id: &str,
|
||||
candidate_id: Option<&str>,
|
||||
report_kind: &str,
|
||||
mut headers: std::collections::BTreeMap<String, String>,
|
||||
telemetry: Option<ExecutionTelemetry>,
|
||||
buffered_body: &[u8],
|
||||
status_code: u16,
|
||||
body_json: Value,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
headers.remove("content-encoding");
|
||||
headers.remove("content-length");
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
|
||||
let payload = GatewaySyncReportRequest {
|
||||
trace_id: trace_id.to_string(),
|
||||
report_kind: report_kind.to_string(),
|
||||
report_context,
|
||||
status_code,
|
||||
headers,
|
||||
body_json: Some(body_json),
|
||||
client_body_json: None,
|
||||
body_base64: (!buffered_body.is_empty())
|
||||
.then(|| base64::engine::general_purpose::STANDARD.encode(buffered_body)),
|
||||
telemetry,
|
||||
};
|
||||
record_stream_sync_failure(state, plan, payload.report_context.as_ref(), &payload, None).await;
|
||||
|
||||
let response =
|
||||
submit_local_core_error_or_sync_finalize(state, trace_id, decision, payload).await?;
|
||||
Ok(Some(attach_control_metadata_headers(
|
||||
response,
|
||||
Some(request_id),
|
||||
candidate_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // internal helper for prefetch error handling
|
||||
pub(super) async fn handle_prefetch_stream_failure(
|
||||
state: &AppState,
|
||||
|
||||
Reference in New Issue
Block a user