mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(provider): 修复 Windsurf 原生工具桥接
This commit is contained in:
5
Makefile
5
Makefile
@@ -335,6 +335,11 @@ if ! command -v curl >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
|
||||
export RUSTC_WRAPPER="$$(command -v sccache)"
|
||||
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
|
||||
fi
|
||||
|
||||
if ! ensure_dev_infra; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) use aether_ai_formats::api::{
|
||||
build_core_error_body_for_client_format, convert_standard_chat_response,
|
||||
core_error_background_report_kind, core_error_default_client_api_format,
|
||||
core_success_background_report_kind, encode_kiro_sse_events,
|
||||
extract_provider_private_stream_error_body,
|
||||
implicit_sync_finalize_report_kind, is_core_error_finalize_kind,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, resolve_claude_stream_spec,
|
||||
|
||||
@@ -20,6 +20,7 @@ mod stream_pump;
|
||||
pub(crate) mod submission;
|
||||
pub(crate) mod sync;
|
||||
pub(crate) mod transport;
|
||||
mod windsurf;
|
||||
|
||||
pub(crate) use self::chatgpt_web_image::maybe_execute_chatgpt_web_image_sync;
|
||||
pub(crate) use self::constants::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -372,7 +372,10 @@ pub(crate) fn resolve_core_success_background_report_kind(report_kind: &str) ->
|
||||
core_success_background_report_kind(report_kind).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn resolve_local_sync_error_status_code(status_code: u16, body_json: &serde_json::Value) -> u16 {
|
||||
pub(crate) fn resolve_local_sync_error_status_code(
|
||||
status_code: u16,
|
||||
body_json: &serde_json::Value,
|
||||
) -> u16 {
|
||||
if (400..600).contains(&status_code) {
|
||||
return status_code;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use async_stream::stream;
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::{HeaderName, HeaderValue, Response, StatusCode};
|
||||
use base64::Engine as _;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -30,7 +29,8 @@ use tracing::{debug, warn};
|
||||
|
||||
use crate::ai_serving::api::{
|
||||
build_core_error_body_for_client_format, implicit_sync_finalize_report_kind,
|
||||
maybe_build_sync_finalize_outcome, LocalCoreSyncErrorKind, LocalCoreSyncFinalizeOutcome,
|
||||
maybe_build_sync_finalize_outcome, extract_provider_private_stream_error_body,
|
||||
LocalCoreSyncErrorKind, LocalCoreSyncFinalizeOutcome,
|
||||
};
|
||||
use crate::api::response::{
|
||||
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
|
||||
@@ -43,12 +43,16 @@ use crate::execution_runtime::grok::maybe_execute_grok_sync;
|
||||
use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
|
||||
#[cfg(test)]
|
||||
use crate::execution_runtime::remote_compat::post_sync_plan_to_remote_execution_runtime;
|
||||
use crate::execution_runtime::submission::submit_local_core_error_or_sync_finalize;
|
||||
use crate::execution_runtime::submission::{
|
||||
resolve_local_sync_error_status_code, submit_local_core_error_or_sync_finalize,
|
||||
};
|
||||
use crate::execution_runtime::transport::{
|
||||
build_execution_response_body,
|
||||
build_request_body, collect_response_headers, decode_response_body_bytes,
|
||||
format_upstream_request_error, format_wreq_upstream_request_error, response_body_is_json,
|
||||
send_request, DirectHttpResponse, DirectSyncExecutionRuntime, ExecutionRuntimeTransportError,
|
||||
};
|
||||
use crate::execution_runtime::windsurf::maybe_execute_windsurf_sync;
|
||||
use crate::execution_runtime::{
|
||||
analyze_local_candidate_failover_sync, apply_endpoint_response_header_rules,
|
||||
attach_provider_response_headers_to_report_context, local_failover_response_text,
|
||||
@@ -398,6 +402,43 @@ fn build_invalid_provider_success_body(
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_private_error_details(body_json: &Value) -> (Option<String>, Option<String>) {
|
||||
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"]);
|
||||
let error_message = first_non_empty_error_text(
|
||||
error_object,
|
||||
body_object,
|
||||
&["message", "detail", "reason", "status", "type", "code"],
|
||||
);
|
||||
(error_type, error_message)
|
||||
}
|
||||
|
||||
fn first_non_empty_error_text(
|
||||
error_object: Option<&serde_json::Map<String, Value>>,
|
||||
body_object: Option<&serde_json::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
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OpenAiImageSyncProgressSnapshot {
|
||||
phase: &'static str,
|
||||
@@ -788,6 +829,12 @@ async fn execute_direct_sync_runtime_candidate(
|
||||
candidate_index: &str,
|
||||
progress_snapshot: Option<Arc<Mutex<OpenAiImageSyncProgressSnapshot>>>,
|
||||
) -> Result<ExecutionResult, SyncExecutionFailure> {
|
||||
if let Some(result) = maybe_execute_windsurf_sync(state, plan, report_context)
|
||||
.await
|
||||
.map_err(SyncExecutionFailure::from_transport)?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
if !should_track_openai_image_sync_upstream_sse(plan_kind, plan, report_context) {
|
||||
return DirectSyncExecutionRuntime::new()
|
||||
.execute_sync(plan)
|
||||
@@ -945,27 +992,9 @@ async fn execute_openai_image_sync_upstream_sse_candidate(
|
||||
let upstream_bytes = body_bytes.len() as u64;
|
||||
progress.finish(status_code, elapsed_ms).await;
|
||||
|
||||
let body = if body_bytes.is_empty() {
|
||||
None
|
||||
} else if plan.stream {
|
||||
Some(aether_contracts::ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
} else if response_body_is_json(&headers, &decoded_body_bytes) {
|
||||
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
|
||||
.map_err(ExecutionRuntimeTransportError::InvalidJson)
|
||||
let body =
|
||||
build_execution_response_body(&headers, &body_bytes, &decoded_body_bytes, plan.stream)
|
||||
.map_err(SyncExecutionFailure::from_transport)?;
|
||||
Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(body_json),
|
||||
body_bytes_b64: None,
|
||||
})
|
||||
} else {
|
||||
Some(aether_contracts::ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
};
|
||||
|
||||
Ok(ExecutionResult {
|
||||
request_id: plan.request_id.clone(),
|
||||
@@ -1696,8 +1725,21 @@ async fn execute_execution_runtime_sync_impl(
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
}
|
||||
}
|
||||
let (result_error_type, result_error_message) =
|
||||
let (mut result_error_type, mut result_error_message) =
|
||||
execution_error_details(result.error.as_ref(), body_json.as_ref());
|
||||
if result.status_code < 400 {
|
||||
if let Some(error_body_json) =
|
||||
extract_provider_private_stream_error_body(report_context.as_ref(), &body_bytes)
|
||||
{
|
||||
result.status_code =
|
||||
resolve_local_sync_error_status_code(result.status_code, &error_body_json);
|
||||
let (private_error_type, private_error_message) =
|
||||
provider_private_error_details(&error_body_json);
|
||||
result_error_type = private_error_type.or(result_error_type);
|
||||
result_error_message = private_error_message.or(result_error_message);
|
||||
body_json = Some(error_body_json);
|
||||
}
|
||||
}
|
||||
let local_failover_response_text = local_failover_response_text(
|
||||
body_json.as_ref(),
|
||||
&body_bytes,
|
||||
|
||||
@@ -27,6 +27,7 @@ use thiserror::Error;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::execution_runtime::remote_compat::execute_sync_plan_via_remote_execution_runtime;
|
||||
use crate::execution_runtime::windsurf::maybe_execute_windsurf_sync;
|
||||
use crate::frontdoor_loop_guard::{
|
||||
configured_gateway_frontdoor_base_url, gateway_frontdoor_self_loop_guard_error,
|
||||
};
|
||||
@@ -232,26 +233,8 @@ impl DirectSyncExecutionRuntime {
|
||||
let elapsed_ms = started_at.elapsed().as_millis() as u64;
|
||||
let upstream_bytes = body_bytes.len() as u64;
|
||||
|
||||
let body = if body_bytes.is_empty() {
|
||||
None
|
||||
} else if plan.stream {
|
||||
Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
} else if response_body_is_json(&headers, &decoded_body_bytes) {
|
||||
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
|
||||
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
|
||||
Some(ResponseBody {
|
||||
json_body: Some(body_json),
|
||||
body_bytes_b64: None,
|
||||
})
|
||||
} else {
|
||||
Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
};
|
||||
let body =
|
||||
build_execution_response_body(&headers, &body_bytes, &decoded_body_bytes, plan.stream)?;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
request_id: plan.request_id.clone(),
|
||||
@@ -347,6 +330,11 @@ pub(crate) async fn execute_sync_plan_with_report_context(
|
||||
}
|
||||
|
||||
let _ = trace_id;
|
||||
match maybe_execute_windsurf_sync(state, plan, None).await {
|
||||
Ok(Some(result)) => return Ok(result),
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(GatewayError::Internal(err.to_string())),
|
||||
}
|
||||
match DirectSyncExecutionRuntime::new().execute_sync(plan).await {
|
||||
Ok(result) => {
|
||||
record_manual_proxy_request_outcome(state, plan, result.status_code).await;
|
||||
@@ -567,26 +555,8 @@ async fn execute_sync_plan_via_local_tunnel(
|
||||
);
|
||||
}
|
||||
|
||||
let body = if body_bytes.is_empty() {
|
||||
None
|
||||
} else if plan.stream {
|
||||
Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
} else if response_body_is_json(&headers, &decoded_body_bytes) {
|
||||
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
|
||||
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
|
||||
Some(ResponseBody {
|
||||
json_body: Some(body_json),
|
||||
body_bytes_b64: None,
|
||||
})
|
||||
} else {
|
||||
Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
|
||||
})
|
||||
};
|
||||
let body =
|
||||
build_execution_response_body(&headers, &body_bytes, &decoded_body_bytes, plan.stream)?;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
request_id: plan.request_id.clone(),
|
||||
@@ -1486,6 +1456,50 @@ pub(crate) fn response_body_is_json(headers: &BTreeMap<String, String>, body_byt
|
||||
serde_json::from_slice::<Value>(body_bytes).is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn build_execution_response_body(
|
||||
headers: &BTreeMap<String, String>,
|
||||
body_bytes: &[u8],
|
||||
decoded_body_bytes: &[u8],
|
||||
stream: bool,
|
||||
) -> Result<Option<ResponseBody>, ExecutionRuntimeTransportError> {
|
||||
if body_bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(body_json) =
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, decoded_body_bytes)
|
||||
.or_else(|| {
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, body_bytes)
|
||||
})
|
||||
{
|
||||
return Ok(Some(ResponseBody {
|
||||
json_body: Some(body_json),
|
||||
body_bytes_b64: None,
|
||||
}));
|
||||
}
|
||||
|
||||
if stream {
|
||||
return Ok(Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||
}));
|
||||
}
|
||||
|
||||
if response_body_is_json(headers, decoded_body_bytes) {
|
||||
let body_json: Value = serde_json::from_slice(decoded_body_bytes)
|
||||
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
|
||||
return Ok(Some(ResponseBody {
|
||||
json_body: Some(body_json),
|
||||
body_bytes_b64: None,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body_bytes)),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -1510,7 +1524,8 @@ mod tests {
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::{
|
||||
build_browser_wreq_client, build_client, build_request_headers, execute_sync_plan,
|
||||
build_browser_wreq_client, build_client, build_execution_response_body,
|
||||
build_request_headers, execute_sync_plan,
|
||||
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
|
||||
record_manual_proxy_request_success, record_manual_proxy_stream_error,
|
||||
resolve_execution_transport_controls, response_body_is_json, DirectSyncExecutionRuntime,
|
||||
@@ -2780,6 +2795,30 @@ mod tests {
|
||||
assert!(!response_body_is_json(&headers, &body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_json_error_response_is_decoded_for_stream_sync_body() {
|
||||
let headers = BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/connect+json".to_string(),
|
||||
)]);
|
||||
let payload = br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#;
|
||||
let mut body_bytes = vec![2];
|
||||
body_bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
body_bytes.extend_from_slice(payload);
|
||||
|
||||
let body = build_execution_response_body(&headers, &body_bytes, &body_bytes, true)
|
||||
.expect("body should build")
|
||||
.expect("body should be present");
|
||||
|
||||
assert_eq!(
|
||||
body.json_body
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/error/code")),
|
||||
Some(&json!("resource_exhausted"))
|
||||
);
|
||||
assert!(body.body_bytes_b64.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_sync_execution_runtime_compresses_json_body_when_requested() {
|
||||
let listener = crate::test_support::bind_loopback_listener()
|
||||
|
||||
4947
apps/aether-gateway/src/execution_runtime/windsurf.rs
Normal file
4947
apps/aether-gateway/src/execution_runtime/windsurf.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,11 @@ use super::local_monitoring_response;
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_data_contracts::repository::{
|
||||
candidates::RequestCandidateStatus, usage::UsageBodyCaptureState,
|
||||
};
|
||||
use axum::body::to_bytes;
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -530,6 +533,89 @@ async fn admin_monitoring_trace_request_exposes_failed_candidate_upstream_respon
|
||||
assert!(extra.get("provider_response").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_decodes_connect_json_response_body_refs() {
|
||||
let mut candidate = sample_candidate(
|
||||
"cand-used",
|
||||
"request-connect",
|
||||
0,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(101),
|
||||
Some(33),
|
||||
Some(429),
|
||||
);
|
||||
candidate.extra_data = Some(json!({"cache_1h": true}));
|
||||
|
||||
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![candidate]));
|
||||
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
));
|
||||
let mut usage = sample_usage(
|
||||
"request-connect",
|
||||
"provider-1",
|
||||
"Windsurf",
|
||||
0,
|
||||
0.0,
|
||||
"failed",
|
||||
Some(429),
|
||||
100,
|
||||
);
|
||||
usage.candidate_id = Some("cand-used".to_string());
|
||||
usage.response_headers = Some(json!({
|
||||
"content-type": "application/connect+json"
|
||||
}));
|
||||
let mut framed = Vec::new();
|
||||
framed.push(2);
|
||||
let payload = br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#;
|
||||
framed.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
framed.extend_from_slice(payload);
|
||||
usage.response_body = Some(json!(BASE64_STANDARD.encode(framed)));
|
||||
usage.response_body_ref = Some("usage://request/request-connect/response_body".to_string());
|
||||
usage.response_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![usage]));
|
||||
let data_state =
|
||||
crate::data::GatewayDataState::with_request_candidate_and_usage_repository_for_tests(
|
||||
request_candidates,
|
||||
usage_repository,
|
||||
)
|
||||
.with_provider_catalog_reader(provider_catalog);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let context = request_context(
|
||||
http::Method::GET,
|
||||
"/api/admin/monitoring/trace/request-connect",
|
||||
);
|
||||
|
||||
let response = local_monitoring_response(&state, &context)
|
||||
.await
|
||||
.expect("handler should not error")
|
||||
.expect("route should be handled locally");
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
|
||||
let upstream_response = &payload["candidates"][0]["extra_data"]["upstream_response"];
|
||||
assert_eq!(upstream_response["status_code"], json!(429));
|
||||
assert_eq!(
|
||||
upstream_response["body"]["error"]["code"],
|
||||
json!("resource_exhausted")
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_response["body"]["error"]["message"],
|
||||
json!("quota exhausted")
|
||||
);
|
||||
assert_eq!(
|
||||
upstream_response["body_ref"],
|
||||
json!("usage://request/request-connect/response_body")
|
||||
);
|
||||
assert_eq!(upstream_response["body_state"], json!("inline"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_monitoring_trace_request_exposes_structured_ranking_metadata() {
|
||||
let mut candidate = sample_candidate(
|
||||
|
||||
@@ -3158,17 +3158,22 @@ async fn provider_query_execute_windsurf_test_candidate(
|
||||
}
|
||||
|
||||
let incoming_request_headers = provider_query_extract_request_headers(payload);
|
||||
let mut request_body = original_request_body.clone();
|
||||
if let Some(object) = request_body.as_object_mut() {
|
||||
object.insert("stream".to_string(), Value::Bool(false));
|
||||
}
|
||||
let request_body = original_request_body.clone();
|
||||
let request_model =
|
||||
provider_query_request_body_model(&request_body, &candidate.effective_model);
|
||||
let upstream_is_stream = provider_query_resolve_standard_test_upstream_is_stream(
|
||||
transport.endpoint.config.as_ref(),
|
||||
let client_is_stream = request_body
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let hard_requires_streaming = crate::ai_serving::force_upstream_streaming_for_provider(
|
||||
transport.provider.provider_type.as_str(),
|
||||
candidate.endpoint.api_format.as_str(),
|
||||
);
|
||||
let upstream_is_stream = crate::ai_serving::resolve_upstream_is_stream_from_endpoint_config(
|
||||
transport.endpoint.config.as_ref(),
|
||||
client_is_stream,
|
||||
hard_requires_streaming,
|
||||
);
|
||||
let Some((auth_header, auth_value)) =
|
||||
crate::provider_transport::windsurf::resolve_windsurf_cascade_auth(&transport).or_else(
|
||||
|| crate::provider_transport::auth::resolve_local_openai_bearer_auth(&transport),
|
||||
|
||||
@@ -42,9 +42,9 @@ pub(super) fn provider_query_test_attempt_payload(
|
||||
"status_code": execution.status_code,
|
||||
"latency_ms": execution.latency_ms,
|
||||
"request_url": execution.request_url,
|
||||
"request_headers": provider_query_redact_diagnostic_headers(&execution.request_headers),
|
||||
"request_body": execution.request_body,
|
||||
"response_headers": provider_query_redact_diagnostic_headers(&execution.response_headers),
|
||||
"request_headers": redacted_provider_query_headers(&execution.request_headers),
|
||||
"request_body": redacted_provider_query_value(&execution.request_body),
|
||||
"response_headers": redacted_provider_query_headers(&execution.response_headers),
|
||||
"response_body": execution.response_body,
|
||||
})
|
||||
}
|
||||
@@ -172,34 +172,84 @@ fn provider_query_endpoint_route_payload(
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_query_redact_diagnostic_headers(
|
||||
headers: &BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
fn redacted_provider_query_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
if provider_query_header_is_sensitive(name) {
|
||||
(name.clone(), "<redacted>".to_string())
|
||||
.map(|(key, value)| {
|
||||
if provider_query_field_is_sensitive(key) {
|
||||
(key.clone(), "[REDACTED]".to_string())
|
||||
} else {
|
||||
(name.clone(), value.clone())
|
||||
(key.clone(), value.clone())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_query_header_is_sensitive(name: &str) -> bool {
|
||||
fn redacted_provider_query_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
if provider_query_field_is_sensitive(key) {
|
||||
(key.clone(), Value::String("[REDACTED]".to_string()))
|
||||
} else {
|
||||
(key.clone(), redacted_provider_query_value(value))
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(redacted_provider_query_value)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_query_field_is_sensitive(key: &str) -> bool {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let normalized = key
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.collect::<String>();
|
||||
if matches!(
|
||||
normalized.as_str(),
|
||||
"maxtokens"
|
||||
| "maxoutputtokens"
|
||||
| "inputtokens"
|
||||
| "outputtokens"
|
||||
| "prompttokens"
|
||||
| "completiontokens"
|
||||
| "totaltokens"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
name.trim().to_ascii_lowercase().as_str(),
|
||||
key.as_str(),
|
||||
"authorization"
|
||||
| "proxy-authorization"
|
||||
| "cookie"
|
||||
| "set-cookie"
|
||||
| "x-api-key"
|
||||
| "api_key"
|
||||
| "apikey"
|
||||
| "api-key"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "anthropic-api-key"
|
||||
| "openai-api-key"
|
||||
)
|
||||
| "x-codeium-csrf-token"
|
||||
| "access_token"
|
||||
| "refresh_token"
|
||||
| "id_token"
|
||||
| "password"
|
||||
| "secret"
|
||||
) || normalized.ends_with("token")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized.contains("apikey")
|
||||
|| normalized.contains("authorization")
|
||||
}
|
||||
|
||||
pub(super) fn provider_query_candidate_summary_payload(
|
||||
@@ -309,34 +359,93 @@ pub(super) fn provider_query_candidate_summary_payload(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::{redacted_provider_query_headers, redacted_provider_query_value};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn provider_query_diagnostic_headers_redact_credentials() {
|
||||
fn redacts_sensitive_provider_query_headers() {
|
||||
let headers = BTreeMap::from([
|
||||
("cookie".to_string(), "sso=secret".to_string()),
|
||||
("authorization".to_string(), "Bearer secret".to_string()),
|
||||
(
|
||||
"authorization".to_string(),
|
||||
"Bearer secret-token".to_string(),
|
||||
),
|
||||
("x-goog-api-key".to_string(), "secret".to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
(
|
||||
"x-codeium-csrf-token".to_string(),
|
||||
"csrf-secret".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let redacted = provider_query_redact_diagnostic_headers(&headers);
|
||||
let redacted = redacted_provider_query_headers(&headers);
|
||||
|
||||
assert_eq!(
|
||||
redacted.get("cookie").map(String::as_str),
|
||||
Some("<redacted>")
|
||||
Some("[REDACTED]")
|
||||
);
|
||||
assert_eq!(
|
||||
redacted.get("authorization").map(String::as_str),
|
||||
Some("<redacted>")
|
||||
Some("[REDACTED]")
|
||||
);
|
||||
assert_eq!(
|
||||
redacted.get("x-goog-api-key").map(String::as_str),
|
||||
Some("<redacted>")
|
||||
Some("[REDACTED]")
|
||||
);
|
||||
assert_eq!(
|
||||
redacted.get("x-codeium-csrf-token").map(String::as_str),
|
||||
Some("[REDACTED]")
|
||||
);
|
||||
assert_eq!(
|
||||
redacted.get("content-type").map(String::as_str),
|
||||
Some("application/json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_provider_query_request_body_fields() {
|
||||
let body = json!({
|
||||
"metadata": {
|
||||
"apiKey": "devin-session-token$secret",
|
||||
"ideName": "windsurf"
|
||||
},
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let redacted = redacted_provider_query_value(&body);
|
||||
|
||||
assert_eq!(
|
||||
redacted.pointer("/metadata/apiKey"),
|
||||
Some(&json!("[REDACTED]"))
|
||||
);
|
||||
assert_eq!(
|
||||
redacted.pointer("/metadata/ideName"),
|
||||
Some(&json!("windsurf"))
|
||||
);
|
||||
assert_eq!(redacted.pointer("/stream"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_non_secret_token_count_fields_visible() {
|
||||
let body = json!({
|
||||
"maxTokens": 64,
|
||||
"usage": {
|
||||
"inputTokens": 10,
|
||||
"outputTokens": 2,
|
||||
"accessToken": "secret"
|
||||
}
|
||||
});
|
||||
|
||||
let redacted = redacted_provider_query_value(&body);
|
||||
|
||||
assert_eq!(redacted.pointer("/maxTokens"), Some(&json!(64)));
|
||||
assert_eq!(redacted.pointer("/usage/inputTokens"), Some(&json!(10)));
|
||||
assert_eq!(redacted.pointer("/usage/outputTokens"), Some(&json!(2)));
|
||||
assert_eq!(
|
||||
redacted.pointer("/usage/accessToken"),
|
||||
Some(&json!("[REDACTED]"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,17 +1268,11 @@ fn build_windsurf_quota_status_snapshot(
|
||||
let retry_after_ms = rate_limit_object
|
||||
.get("retry_after_ms")
|
||||
.or_else(|| rate_limit_object.get("retryAfterMs"))
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||
let limited = rate_limit_object
|
||||
.get("limited")
|
||||
.or_else(|| rate_limit_object.get("is_limited"))
|
||||
.or_else(|| rate_limit_object.get("isLimited"))
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
== Some(true);
|
||||
if limited || retry_after_ms.is_some() {
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
||||
.filter(|value| *value > 0);
|
||||
if let Some(retry_after_ms) = retry_after_ms {
|
||||
rate_limit_cooling = true;
|
||||
rate_limit_reset_seconds =
|
||||
retry_after_ms.map(|value| value.saturating_add(999) / 1000);
|
||||
rate_limit_reset_seconds = Some(retry_after_ms.saturating_add(999) / 1000);
|
||||
rate_limit_reason = rate_limit_object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1674,6 +1668,12 @@ fn quota_snapshot_has_materialized_data(
|
||||
return false;
|
||||
}
|
||||
|
||||
if normalized_provider_type == "windsurf"
|
||||
&& windsurf_quota_snapshot_has_stale_cooldown(quota_snapshot)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if quota_snapshot
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
@@ -1699,6 +1699,61 @@ fn quota_snapshot_has_materialized_data(
|
||||
})
|
||||
}
|
||||
|
||||
fn windsurf_quota_snapshot_has_stale_cooldown(quota_snapshot: &Map<String, Value>) -> bool {
|
||||
let code = quota_snapshot
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !code.eq_ignore_ascii_case("cooldown") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let rate_limit = quota_snapshot.get("rate_limit").and_then(Value::as_object);
|
||||
let retry_after_ms = rate_limit
|
||||
.and_then(|rate_limit| {
|
||||
rate_limit
|
||||
.get("retry_after_ms")
|
||||
.or_else(|| rate_limit.get("retryAfterMs"))
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if retry_after_ms > 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let has_positive_rate_limit_reset = quota_snapshot
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|windows| {
|
||||
windows.iter().filter_map(Value::as_object).any(|window| {
|
||||
window
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|code| code.eq_ignore_ascii_case("rate_limit"))
|
||||
&& window
|
||||
.get("reset_seconds")
|
||||
.or_else(|| window.get("reset_at"))
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
||||
.is_some_and(|value| value > 0)
|
||||
})
|
||||
});
|
||||
if has_positive_rate_limit_reset {
|
||||
return false;
|
||||
}
|
||||
|
||||
let exhausted = quota_snapshot
|
||||
.get("exhausted")
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
.unwrap_or(false);
|
||||
let has_capacity = rate_limit
|
||||
.and_then(|rate_limit| rate_limit.get("has_capacity"))
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
has_capacity || !exhausted
|
||||
}
|
||||
|
||||
pub(crate) fn provider_key_status_snapshot_payload(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
@@ -2671,6 +2726,123 @@ mod tests {
|
||||
assert_eq!(rate_window.get("reset_seconds"), Some(&json!(61u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_keeps_windsurf_capacity_probe_without_retry_after_ok() {
|
||||
let mut key = sample_catalog_key();
|
||||
key.upstream_metadata = Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_778_067_246u64,
|
||||
"daily_remaining_percent": 100.0,
|
||||
"weekly_remaining_percent": 100.0,
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"has_capacity": false,
|
||||
"messages_remaining": 0.0,
|
||||
"max_messages": 100.0
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let payload = provider_key_status_snapshot_payload(&key, "windsurf");
|
||||
let quota = payload
|
||||
.get("quota")
|
||||
.and_then(Value::as_object)
|
||||
.expect("quota snapshot should be object");
|
||||
let has_rate_limit_window =
|
||||
quota
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|windows| {
|
||||
windows
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.any(|window| window.get("code") == Some(&json!("rate_limit")))
|
||||
});
|
||||
|
||||
assert_eq!(quota.get("code"), Some(&json!("ok")));
|
||||
assert_eq!(quota.get("label"), Some(&Value::Null));
|
||||
assert_eq!(quota.get("exhausted"), Some(&json!(false)));
|
||||
assert_eq!(
|
||||
payload.pointer("/quota/rate_limit/limited"),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert!(!has_rate_limit_window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_refreshes_stale_windsurf_cooldown_when_probe_has_capacity(
|
||||
) {
|
||||
let mut key = sample_catalog_key();
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "windsurf",
|
||||
"code": "cooldown",
|
||||
"label": "冷却中",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{
|
||||
"code": "daily",
|
||||
"unit": "percent",
|
||||
"label": "日",
|
||||
"scope": "account",
|
||||
"remaining_ratio": 0.99,
|
||||
"is_exhausted": false
|
||||
},
|
||||
{
|
||||
"code": "rate_limit",
|
||||
"unit": "count",
|
||||
"label": "速率",
|
||||
"scope": "account",
|
||||
"is_exhausted": false,
|
||||
"reset_seconds": null
|
||||
}
|
||||
],
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"has_capacity": true,
|
||||
"messages_remaining": -1,
|
||||
"max_messages": -1
|
||||
}
|
||||
}
|
||||
}));
|
||||
key.upstream_metadata = Some(json!({
|
||||
"windsurf": {
|
||||
"updated_at": 1_778_067_246u64,
|
||||
"daily_remaining_percent": 99.0,
|
||||
"weekly_remaining_percent": 100.0,
|
||||
"allowed_models_count": 118,
|
||||
"rate_limit": {
|
||||
"limited": true,
|
||||
"has_capacity": true,
|
||||
"messages_remaining": -1,
|
||||
"max_messages": -1
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let payload = provider_key_status_snapshot_payload(&key, "windsurf");
|
||||
let quota = payload
|
||||
.get("quota")
|
||||
.and_then(Value::as_object)
|
||||
.expect("quota snapshot should be object");
|
||||
let has_rate_limit_window =
|
||||
quota
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|windows| {
|
||||
windows
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.any(|window| window.get("code") == Some(&json!("rate_limit")))
|
||||
});
|
||||
|
||||
assert_eq!(quota.get("code"), Some(&json!("ok")));
|
||||
assert_eq!(quota.get("label"), Some(&Value::Null));
|
||||
assert_eq!(quota.get("allowed_models_count"), Some(&json!(118u64)));
|
||||
assert!(!has_rate_limit_window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_marks_windsurf_banned_and_quarantined_blocking() {
|
||||
let mut banned_key = sample_catalog_key();
|
||||
|
||||
@@ -2383,6 +2383,122 @@ async fn gateway_routes_grok_responses_admin_pool_model_test_through_grok_runtim
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_streams_windsurf_connect_upstream_for_admin_model_test() {
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| async move {
|
||||
assert_eq!(plan.provider_id, "provider-windsurf");
|
||||
assert_eq!(plan.endpoint_id, "endpoint-windsurf-chat");
|
||||
assert_eq!(plan.key_id, "key-windsurf-primary");
|
||||
assert_eq!(plan.provider_api_format, "openai:chat");
|
||||
assert_eq!(plan.content_type.as_deref(), Some("application/connect+json"));
|
||||
assert!(plan.stream, "Windsurf Connect model test must stream upstream");
|
||||
assert_eq!(
|
||||
plan.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("stream")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
let windsurf_payload = serde_json::to_vec(&json!({
|
||||
"chatMessage": {
|
||||
"text": "ok"
|
||||
}
|
||||
}))
|
||||
.expect("windsurf payload should encode");
|
||||
let mut windsurf_frame = vec![0u8];
|
||||
windsurf_frame.extend_from_slice(&(windsurf_payload.len() as u32).to_be_bytes());
|
||||
windsurf_frame.extend_from_slice(&windsurf_payload);
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"candidate_id": plan.candidate_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/connect+json"
|
||||
},
|
||||
"body": {
|
||||
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(windsurf_frame)
|
||||
},
|
||||
"telemetry": {
|
||||
"elapsed_ms": 24
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-windsurf", "Windsurf", 10);
|
||||
provider.provider_type = "windsurf".to_string();
|
||||
let mut key = sample_key(
|
||||
"key-windsurf-primary",
|
||||
"provider-windsurf",
|
||||
"openai:chat",
|
||||
"devin-session-token$abc",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-windsurf-chat",
|
||||
"provider-windsurf",
|
||||
"openai:chat",
|
||||
"https://server.codeium.com",
|
||||
)],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/provider-query/test-model"))
|
||||
.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(&json!({
|
||||
"provider_id": "provider-windsurf",
|
||||
"model": "claude-opus-4-7-medium",
|
||||
"api_format": "openai:chat",
|
||||
"endpoint_id": "endpoint-windsurf-chat",
|
||||
"request_body": {
|
||||
"model": "claude-opus-4-7-medium",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Hello! This is a test message."
|
||||
}],
|
||||
"max_tokens": 30,
|
||||
"temperature": 0.7,
|
||||
"stream": true
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], json!(true));
|
||||
assert_eq!(
|
||||
payload["attempts"][0]["request_body"]["stream"],
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["attempts"][0]["response_body"]["choices"][0]["message"]["content"],
|
||||
json!("ok")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_uses_pool_scheduler_order_for_admin_pool_model_test() {
|
||||
let execution_runtime = Router::new().route(
|
||||
|
||||
@@ -9,6 +9,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -548,16 +549,69 @@ fn admin_monitoring_trace_response_data(
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = admin_monitoring_trace_response_body(headers, body);
|
||||
Some(json!({
|
||||
"source": source,
|
||||
"status_code": status_code,
|
||||
"headers": headers.cloned().unwrap_or(Value::Null),
|
||||
"body": body.cloned().unwrap_or(Value::Null),
|
||||
"body": body.unwrap_or(Value::Null),
|
||||
"body_ref": body_ref,
|
||||
"body_state": body_state.map(|state| state.as_str()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn admin_monitoring_trace_response_body(
|
||||
headers: Option<&Value>,
|
||||
body: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let body = body?;
|
||||
admin_monitoring_decode_connect_json_error_body(headers, body).or_else(|| Some(body.clone()))
|
||||
}
|
||||
|
||||
fn admin_monitoring_decode_connect_json_error_body(
|
||||
headers: Option<&Value>,
|
||||
body: &Value,
|
||||
) -> Option<Value> {
|
||||
if !admin_monitoring_headers_indicate_connect_json(headers) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_base64 = match body {
|
||||
Value::String(value) => Some(value.as_str()),
|
||||
Value::Object(object) => object
|
||||
.get("encoding")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
|
||||
.then(|| object.get("data").and_then(Value::as_str))
|
||||
.flatten(),
|
||||
_ => None,
|
||||
}?
|
||||
.trim();
|
||||
if body_base64.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body_bytes = BASE64_STANDARD.decode(body_base64).ok()?;
|
||||
aether_ai_formats::api::extract_provider_private_stream_error_body(None, &body_bytes)
|
||||
}
|
||||
|
||||
fn admin_monitoring_headers_indicate_connect_json(headers: Option<&Value>) -> bool {
|
||||
headers
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| {
|
||||
object.iter().find_map(|(key, value)| {
|
||||
key.eq_ignore_ascii_case("content-type")
|
||||
.then(|| value.as_str())
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("application/connect+json") || value.contains("+connect+json")
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_admin_monitoring_trace_response(
|
||||
extra_object: &mut serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
|
||||
@@ -211,10 +211,10 @@ pub use crate::provider_compat::kiro_stream::{
|
||||
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
|
||||
};
|
||||
pub use crate::provider_compat::private_envelope::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
pub use crate::provider_compat::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
|
||||
@@ -1299,6 +1299,7 @@ struct OpenAIResponsesClientToolState {
|
||||
name: String,
|
||||
arguments: String,
|
||||
output_index: Option<usize>,
|
||||
web_search: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -1310,6 +1311,23 @@ struct OpenAIResponsesClientToolResultState {
|
||||
item_started: bool,
|
||||
}
|
||||
|
||||
fn is_responses_web_search_tool(name: &str) -> bool {
|
||||
matches!(name, "web_search" | "web_search_preview")
|
||||
}
|
||||
|
||||
fn web_search_query_from_arguments(arguments: &str) -> String {
|
||||
serde_json::from_str::<Value>(arguments)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| value.as_str().map(ToOwned::to_owned))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIResponsesClientEmitter {
|
||||
response_id: Option<String>,
|
||||
@@ -1985,6 +2003,26 @@ impl OpenAIResponsesClientEmitter {
|
||||
} else {
|
||||
state.name.clone()
|
||||
};
|
||||
if state.web_search {
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.done",
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"response_id": self.response_id(),
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": item_id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_arguments(&state.arguments),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)?);
|
||||
continue;
|
||||
}
|
||||
out.extend(self.encode_response_event(
|
||||
"response.function_call_arguments.done",
|
||||
json!({
|
||||
@@ -2143,20 +2181,32 @@ impl OpenAIResponsesClientEmitter {
|
||||
}
|
||||
for (index, state) in &self.tool_calls {
|
||||
if let Some(output_index) = state.output_index {
|
||||
let item_id = if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
};
|
||||
if state.web_search {
|
||||
ordered_output.push((
|
||||
output_index,
|
||||
json!({
|
||||
"type": "web_search_call",
|
||||
"id": item_id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_arguments(&state.arguments),
|
||||
},
|
||||
}),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
ordered_output.push((
|
||||
output_index,
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"call_id": if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(*index)
|
||||
} else {
|
||||
state.call_id.clone()
|
||||
},
|
||||
"id": item_id.clone(),
|
||||
"call_id": item_id,
|
||||
"name": if state.name.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
@@ -2322,22 +2372,36 @@ impl OpenAIResponsesClientEmitter {
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.call_id = call_id.clone();
|
||||
state.name = name.clone();
|
||||
state.web_search = is_responses_web_search_tool(&name);
|
||||
let emitted_call_id = state.call_id.clone();
|
||||
let emitted_name = state.name.clone();
|
||||
let item = if state.web_search {
|
||||
json!({
|
||||
"type": "web_search_call",
|
||||
"id": emitted_call_id,
|
||||
"status": "in_progress",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": "",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": emitted_call_id,
|
||||
"name": emitted_name,
|
||||
"arguments": "",
|
||||
"status": "in_progress",
|
||||
})
|
||||
};
|
||||
out.extend(self.encode_response_event(
|
||||
"response.output_item.added",
|
||||
json!({
|
||||
"type": "response.output_item.added",
|
||||
"response_id": response_id,
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"id": call_id,
|
||||
"call_id": emitted_call_id,
|
||||
"name": emitted_name,
|
||||
"arguments": "",
|
||||
"status": "in_progress",
|
||||
}
|
||||
"item": item
|
||||
}),
|
||||
)?);
|
||||
Ok(out)
|
||||
@@ -2348,6 +2412,9 @@ impl OpenAIResponsesClientEmitter {
|
||||
let response_id = self.response_id().to_string();
|
||||
let state = self.tool_calls.entry(index).or_default();
|
||||
state.arguments.push_str(&arguments);
|
||||
if state.web_search {
|
||||
return Ok(out);
|
||||
}
|
||||
let item_id = if state.call_id.is_empty() {
|
||||
build_generated_tool_call_id(index)
|
||||
} else {
|
||||
@@ -3167,6 +3234,56 @@ mod tests {
|
||||
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_emits_web_search_call_item() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let mut bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index: 0,
|
||||
call_id: "call_ws_1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("tool start should encode");
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index: 0,
|
||||
arguments: r#"{"query":"today tech"}"#.to_string(),
|
||||
},
|
||||
})
|
||||
.expect("arguments should encode"),
|
||||
);
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_123".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
usage: None,
|
||||
},
|
||||
})
|
||||
.expect("finish should encode"),
|
||||
);
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert!(sse.contains("event: response.output_item.added\n"));
|
||||
assert!(sse.contains(r#""type":"web_search_call""#));
|
||||
assert!(sse.contains(r#""status":"in_progress""#));
|
||||
assert!(sse.contains(r#""query":"""#));
|
||||
assert!(sse.contains(r#""type":"search""#));
|
||||
assert!(sse.contains("event: response.output_item.done\n"));
|
||||
assert!(sse.contains(r#""query":"today tech""#));
|
||||
assert!(!sse.contains("response.function_call_arguments.delta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
|
||||
@@ -166,13 +166,25 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(json!({
|
||||
"type": "function_call",
|
||||
"id": id,
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": canonicalize_tool_arguments(input),
|
||||
}));
|
||||
if is_responses_web_search_tool(name) {
|
||||
output.push(json!({
|
||||
"type": "web_search_call",
|
||||
"id": id,
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"query": web_search_query_from_value(input),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
output.push(json!({
|
||||
"type": "function_call",
|
||||
"id": id,
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": canonicalize_tool_arguments(input),
|
||||
}));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
@@ -325,3 +337,73 @@ fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_responses_web_search_tool(name: &str) -> bool {
|
||||
matches!(name, "web_search" | "web_search_preview")
|
||||
}
|
||||
|
||||
fn web_search_query_from_value(input: &Value) -> String {
|
||||
input
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| input.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_emits_web_search_call_for_web_search_tool_use() {
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_test".to_string(),
|
||||
model: "gpt-5-5-low".to_string(),
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id: "call_ws_1".to_string(),
|
||||
name: "web_search".to_string(),
|
||||
input: json!({"query": "today tech"}),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::ToolUse),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), false);
|
||||
|
||||
assert_eq!(body["output"][0]["type"], "web_search_call");
|
||||
assert_eq!(body["output"][0]["id"], "call_ws_1");
|
||||
assert_eq!(body["output"][0]["status"], "completed");
|
||||
assert_eq!(body["output"][0]["action"]["type"], "search");
|
||||
assert_eq!(body["output"][0]["action"]["query"], "today tech");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_parser_reads_web_search_call_as_tool_use() {
|
||||
let body = json!({
|
||||
"id": "resp_test",
|
||||
"model": "gpt-5-5-low",
|
||||
"status": "incomplete",
|
||||
"output": [{
|
||||
"type": "web_search_call",
|
||||
"id": "call_ws_1",
|
||||
"status": "completed",
|
||||
"action": {"type": "search", "query": "today tech"}
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical = from_raw(&body).expect("response should parse");
|
||||
|
||||
assert!(
|
||||
matches!(canonical.content.first(), Some(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
..
|
||||
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1556,6 +1556,39 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
@@ -1729,6 +1762,30 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
|
||||
),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("call_auto_{index}"));
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
|
||||
@@ -325,6 +325,19 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_provider_private_stream_error_body(
|
||||
report_context: Option<&Value>,
|
||||
body: &[u8],
|
||||
) -> Option<Value> {
|
||||
if report_context.is_none_or(report_context_is_windsurf_envelope) {
|
||||
if let Some(error_body) = extract_windsurf_connect_json_error_body(body) {
|
||||
return Some(error_body);
|
||||
}
|
||||
}
|
||||
|
||||
extract_stream_error_event_body(body)
|
||||
}
|
||||
|
||||
impl ProviderPrivateStreamNormalizer<'_> {
|
||||
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.mode {
|
||||
@@ -536,8 +549,62 @@ fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
if extract_windsurf_connect_json_error_body(body).is_some() {
|
||||
return true;
|
||||
}
|
||||
extract_stream_error_event_body(body).is_some()
|
||||
}
|
||||
|
||||
fn extract_windsurf_connect_json_error_body(body: &[u8]) -> Option<Value> {
|
||||
if !buffer_looks_like_connect_frame(body) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut offset = 0usize;
|
||||
while body.len().saturating_sub(offset) >= CONNECT_FRAME_HEADER_BYTES {
|
||||
let flags = body[offset];
|
||||
if flags & !0x03 != 0 {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_be_bytes([
|
||||
body[offset + 1],
|
||||
body[offset + 2],
|
||||
body[offset + 3],
|
||||
body[offset + 4],
|
||||
]) as usize;
|
||||
if len > MAX_CONNECT_JSON_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
let frame_end = offset + CONNECT_FRAME_HEADER_BYTES + len;
|
||||
if body.len() < frame_end {
|
||||
return None;
|
||||
}
|
||||
if flags & 0x01 != 0 {
|
||||
return None;
|
||||
}
|
||||
let payload = &body[offset + CONNECT_FRAME_HEADER_BYTES..frame_end];
|
||||
offset = frame_end;
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(payload).ok()?;
|
||||
if flags & 0x02 != 0 {
|
||||
if let Some(error) = parsed.get("error").filter(|value| !value.is_null()) {
|
||||
return Some(normalize_provider_private_error_body(error.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if looks_like_windsurf_error(&parsed) {
|
||||
return Some(normalize_provider_private_error_body(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_stream_error_event_body(body: &[u8]) -> Option<Value> {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return false;
|
||||
return None;
|
||||
};
|
||||
let mut current_event_type: Option<String> = None;
|
||||
for raw_line in text.lines() {
|
||||
@@ -572,11 +639,35 @@ pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
return Some(normalize_provider_private_error_body(event));
|
||||
}
|
||||
current_event_type = None;
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_provider_private_error_body(error: Value) -> Value {
|
||||
let mut error = if error.get("error").is_some_and(|value| !value.is_null()) {
|
||||
error
|
||||
} else {
|
||||
serde_json::json!({ "error": error })
|
||||
};
|
||||
|
||||
if let Some(error_object) = error.get_mut("error").and_then(Value::as_object_mut) {
|
||||
if !error_object.contains_key("type") {
|
||||
if let Some(kind) = error_object
|
||||
.get("code")
|
||||
.or_else(|| error_object.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
error_object.insert("type".to_string(), Value::String(kind.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
|
||||
fn clear_private_envelope_context(report_context: &Value) -> Value {
|
||||
@@ -717,9 +808,9 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -853,6 +944,30 @@ mod tests {
|
||||
assert!(text.contains(r#""content":"frame chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_windsurf_connect_json_trailer_error_frame() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
assert!(stream_body_contains_error_event(&framed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_connect_json_trailer_error_without_report_context() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
let body = extract_provider_private_stream_error_body(None, &framed)
|
||||
.expect("Connect trailer error should decode without report context");
|
||||
|
||||
assert_eq!(body["error"]["code"], json!("resource_exhausted"));
|
||||
assert_eq!(body["error"]["message"], json!("quota exhausted"));
|
||||
}
|
||||
|
||||
fn connect_json_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(5 + payload.len());
|
||||
out.push(flags);
|
||||
|
||||
@@ -496,6 +496,31 @@ pub fn json_string_list(value: Option<&Value>) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn api_format_priority(api_format: &str) -> Option<(usize, usize)> {
|
||||
MODEL_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(group_index, group)| {
|
||||
group
|
||||
.iter()
|
||||
.position(|candidate| candidate.eq_ignore_ascii_case(api_format))
|
||||
.map(|format_index| (group_index, format_index))
|
||||
})
|
||||
}
|
||||
|
||||
fn sorted_api_formats(formats: BTreeSet<String>) -> Vec<String> {
|
||||
let mut formats = formats.into_iter().collect::<Vec<_>>();
|
||||
formats.sort_by(
|
||||
|left, right| match (api_format_priority(left), api_format_priority(right)) {
|
||||
(Some(left_priority), Some(right_priority)) => left_priority.cmp(&right_priority),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => left.cmp(right),
|
||||
},
|
||||
);
|
||||
formats
|
||||
}
|
||||
|
||||
pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
let mut aggregated = BTreeMap::<String, serde_json::Map<String, Value>>::new();
|
||||
|
||||
@@ -557,7 +582,7 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
if let Some(api_format) = legacy_api_format {
|
||||
merged_formats.insert(api_format);
|
||||
}
|
||||
let merged_formats = merged_formats
|
||||
let merged_formats = sorted_api_formats(merged_formats)
|
||||
.into_iter()
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -877,6 +902,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_models_for_cache_orders_api_formats_by_canonical_priority() {
|
||||
let aggregated = aggregate_models_for_cache(&[
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["claude:messages"]}),
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["openai:responses"]}),
|
||||
json!({"id":"claude-sonnet-4-6","api_formats":["openai:chat"]}),
|
||||
]);
|
||||
assert_eq!(aggregated.len(), 1);
|
||||
assert_eq!(
|
||||
aggregated[0]["api_formats"],
|
||||
json!(["openai:chat", "openai:responses", "claude:messages"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_models_for_cache_preserves_legacy_api_format_field() {
|
||||
let aggregated = aggregate_models_for_cache(&[json!({
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::vertex::{
|
||||
build_vertex_service_account_gemini_embedding_url, resolve_local_vertex_api_key_query_auth,
|
||||
resolve_local_vertex_service_account_auth_config,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TransportRequestUrlParams<'a> {
|
||||
pub provider_api_format: &'a str,
|
||||
|
||||
@@ -15,6 +15,10 @@ use crate::{
|
||||
transport_proxy_is_locally_supported,
|
||||
};
|
||||
|
||||
pub mod cascade;
|
||||
pub mod models;
|
||||
pub mod proto;
|
||||
|
||||
pub const PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
pub const GET_CHAT_MESSAGE_PATH: &str = "/exa.api_server_pb.ApiServerService/GetChatMessage";
|
||||
@@ -122,7 +126,8 @@ pub fn build_windsurf_cascade_request_body(
|
||||
}
|
||||
let conversation_id =
|
||||
extract_conversation_id(body_json).unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let message_text = last_user_message_text(&messages).unwrap_or_else(|| "Continue.".to_string());
|
||||
let message_text =
|
||||
latest_message_snapshot_text(&messages).unwrap_or_else(|| "Continue.".to_string());
|
||||
let mut provider_request_body = json!({
|
||||
"metadata": windsurf_metadata_from_auth(auth_value),
|
||||
"model": mapped_model,
|
||||
@@ -151,6 +156,19 @@ pub fn build_windsurf_cascade_request_body(
|
||||
.as_object_mut()?
|
||||
.insert("topP".to_string(), top_p.clone());
|
||||
}
|
||||
for field in [
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"toolChoice",
|
||||
"parallel_tool_calls",
|
||||
"response_format",
|
||||
] {
|
||||
if let Some(value) = body_json.get(field) {
|
||||
provider_request_body
|
||||
.as_object_mut()?
|
||||
.insert(field.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
@@ -268,20 +286,36 @@ fn string_value(value: Option<&Value>) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn last_user_message_text(messages: &[Value]) -> Option<String> {
|
||||
fn latest_message_snapshot_text(messages: &[Value]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|message| {
|
||||
message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| role == "user")
|
||||
.find_map(|message| {
|
||||
let role = message.get("role").and_then(Value::as_str)?;
|
||||
match role {
|
||||
"user" => openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
"tool" => {
|
||||
let content = openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let tool_call_id = message
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
Some(format!(
|
||||
"<tool_result tool_call_id=\"{}\">\n{content}\n</tool_result>",
|
||||
escape_xml_attr(tool_call_id)
|
||||
))
|
||||
}
|
||||
"assistant" => openai_content_to_text(message.get("content"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.and_then(|message| openai_content_to_text(message.get("content")))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn openai_content_to_text(value: Option<&Value>) -> Option<String> {
|
||||
@@ -304,6 +338,14 @@ fn openai_content_to_text(value: Option<&Value>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_xml_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderMap;
|
||||
@@ -416,6 +458,79 @@ mod tests {
|
||||
assert_eq!(body["maxTokens"], json!(128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_openai_tool_fields_for_native_windsurf_runtime() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5-5-low",
|
||||
"messages": [
|
||||
{"role": "user", "content": "read Cargo.toml"}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"file_path": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}],
|
||||
"tool_choice": "required",
|
||||
"parallel_tool_calls": false,
|
||||
"response_format": {"type": "json_object"}
|
||||
}),
|
||||
"gpt-5-5-low",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["tools"][0]["function"]["name"], json!("Read"));
|
||||
assert_eq!(body["tool_choice"], json!("required"));
|
||||
assert_eq!(body["parallel_tool_calls"], json!(false));
|
||||
assert_eq!(body["response_format"]["type"], json!("json_object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_request_body_message_snapshot_from_latest_tool_result() {
|
||||
let body = build_windsurf_cascade_request_body(
|
||||
&json!({
|
||||
"model": "gpt-5-5-low",
|
||||
"messages": [
|
||||
{"role": "user", "content": "read Cargo.toml"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "Read", "arguments": "{\"file_path\":\"Cargo.toml\"}"}
|
||||
}]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "workspace Cargo.toml content"}
|
||||
]
|
||||
}),
|
||||
"gpt-5-5-low",
|
||||
"Bearer devin-session-token$abc",
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("body should build");
|
||||
|
||||
assert!(body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains(r#"<tool_result tool_call_id="call_1">"#));
|
||||
assert!(body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("workspace Cargo.toml content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cascade_headers_with_connect_protocol_and_auth() {
|
||||
let headers = build_windsurf_cascade_headers(
|
||||
|
||||
1475
crates/aether-provider-transport/src/windsurf/cascade.rs
Normal file
1475
crates/aether-provider-transport/src/windsurf/cascade.rs
Normal file
File diff suppressed because it is too large
Load Diff
390
crates/aether-provider-transport/src/windsurf/models.rs
Normal file
390
crates/aether-provider-transport/src/windsurf/models.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WindsurfModel {
|
||||
pub canonical_name: &'static str,
|
||||
pub enum_value: u32,
|
||||
pub model_uid: Option<&'static str>,
|
||||
pub credit_multiplier: f32,
|
||||
pub provider: &'static str,
|
||||
pub deprecated: bool,
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const MODELS: &[WindsurfModel] = &[
|
||||
WindsurfModel { canonical_name: "claude-3.5-sonnet", enum_value: 166, model_uid: None, credit_multiplier: 2.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-3.7-sonnet", enum_value: 226, model_uid: None, credit_multiplier: 2.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-3.7-sonnet-thinking", enum_value: 227, model_uid: None, credit_multiplier: 3.0, provider: "anthropic", deprecated: true },
|
||||
WindsurfModel { canonical_name: "claude-4-sonnet", enum_value: 281, model_uid: Some("MODEL_CLAUDE_4_SONNET"), credit_multiplier: 2.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-sonnet-thinking", enum_value: 282, model_uid: Some("MODEL_CLAUDE_4_SONNET_THINKING"), credit_multiplier: 3.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-opus", enum_value: 290, model_uid: Some("MODEL_CLAUDE_4_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4-opus-thinking", enum_value: 291, model_uid: Some("MODEL_CLAUDE_4_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.1-opus", enum_value: 328, model_uid: Some("MODEL_CLAUDE_4_1_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.1-opus-thinking", enum_value: 329, model_uid: Some("MODEL_CLAUDE_4_1_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-haiku", enum_value: 0, model_uid: Some("MODEL_PRIVATE_11"), credit_multiplier: 1.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-sonnet", enum_value: 353, model_uid: Some("MODEL_PRIVATE_2"), credit_multiplier: 2.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-sonnet-thinking", enum_value: 354, model_uid: Some("MODEL_PRIVATE_3"), credit_multiplier: 3.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-opus", enum_value: 391, model_uid: Some("MODEL_CLAUDE_4_5_OPUS"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-4.5-opus-thinking", enum_value: 392, model_uid: Some("MODEL_CLAUDE_4_5_OPUS_THINKING"), credit_multiplier: 5.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6", enum_value: 0, model_uid: Some("claude-sonnet-4-6"), credit_multiplier: 4.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-thinking", enum_value: 0, model_uid: Some("claude-sonnet-4-6-thinking"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-1m", enum_value: 0, model_uid: Some("claude-sonnet-4-6-1m"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-sonnet-4.6-thinking-1m", enum_value: 0, model_uid: Some("claude-sonnet-4-6-thinking-1m"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4.6", enum_value: 0, model_uid: Some("claude-opus-4-6"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4.6-thinking", enum_value: 0, model_uid: Some("claude-opus-4-6-thinking"), credit_multiplier: 8.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-medium", enum_value: 0, model_uid: Some("claude-opus-4-7-medium"), credit_multiplier: 8.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-low", enum_value: 0, model_uid: Some("claude-opus-4-7-low"), credit_multiplier: 6.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-high", enum_value: 0, model_uid: Some("claude-opus-4-7-high"), credit_multiplier: 10.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-xhigh", enum_value: 0, model_uid: Some("claude-opus-4-7-xhigh"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-medium-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-medium-thinking"), credit_multiplier: 10.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-high-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-high-thinking"), credit_multiplier: 12.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-xhigh-thinking", enum_value: 0, model_uid: Some("claude-opus-4-7-xhigh-thinking"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "claude-opus-4-7-max", enum_value: 0, model_uid: Some("claude-opus-4-7-max"), credit_multiplier: 16.0, provider: "anthropic", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4o", enum_value: 109, model_uid: Some("MODEL_CHAT_GPT_4O_2024_08_06"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4o-mini", enum_value: 113, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-4.1", enum_value: 259, model_uid: Some("MODEL_CHAT_GPT_4_1_2025_04_14"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-4.1-mini", enum_value: 260, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-4.1-nano", enum_value: 261, model_uid: None, credit_multiplier: 0.25, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-5", enum_value: 340, model_uid: Some("MODEL_PRIVATE_6"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_7"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-high", enum_value: 0, model_uid: Some("MODEL_PRIVATE_8"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5-mini", enum_value: 337, model_uid: None, credit_multiplier: 0.25, provider: "openai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "gpt-5-codex", enum_value: 346, model_uid: Some("MODEL_CHAT_GPT_5_CODEX"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1", enum_value: 0, model_uid: Some("MODEL_PRIVATE_12"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-low", enum_value: 0, model_uid: Some("MODEL_PRIVATE_13"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_14"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-high", enum_value: 0, model_uid: Some("MODEL_PRIVATE_15"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_20"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-low-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_21"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-medium-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_22"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-high-fast", enum_value: 0, model_uid: Some("MODEL_PRIVATE_23"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_LOW"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-medium", enum_value: 0, model_uid: Some("MODEL_PRIVATE_9"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-mini-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MINI_LOW"), credit_multiplier: 0.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-mini", enum_value: 0, model_uid: Some("MODEL_PRIVATE_19"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-medium", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_MEDIUM"), credit_multiplier: 1.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.1-codex-max-high", enum_value: 0, model_uid: Some("MODEL_GPT_5_1_CODEX_MAX_HIGH"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2", enum_value: 401, model_uid: Some("MODEL_GPT_5_2_MEDIUM"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-none", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_NONE"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-low", enum_value: 400, model_uid: Some("MODEL_GPT_5_2_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-high", enum_value: 402, model_uid: Some("MODEL_GPT_5_2_HIGH"), credit_multiplier: 3.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-xhigh", enum_value: 403, model_uid: Some("MODEL_GPT_5_2_XHIGH"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-none-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_NONE_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-low-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_LOW_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-medium-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_MEDIUM_PRIORITY"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-high-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_HIGH_PRIORITY"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-xhigh-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_XHIGH_PRIORITY"), credit_multiplier: 16.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-low", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_LOW"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-medium", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_MEDIUM"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-high", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_HIGH"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-xhigh", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_XHIGH"), credit_multiplier: 3.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-low-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_LOW_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-medium-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_MEDIUM_PRIORITY"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-high-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_HIGH_PRIORITY"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.2-codex-xhigh-fast", enum_value: 0, model_uid: Some("MODEL_GPT_5_2_CODEX_XHIGH_PRIORITY"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex", enum_value: 0, model_uid: Some("gpt-5-3-codex-medium"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-none", enum_value: 0, model_uid: Some("gpt-5-4-none"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-low", enum_value: 0, model_uid: Some("gpt-5-4-low"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-medium", enum_value: 0, model_uid: Some("gpt-5-4-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-high", enum_value: 0, model_uid: Some("gpt-5-4-high"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-xhigh", enum_value: 0, model_uid: Some("gpt-5-4-xhigh"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-low", enum_value: 0, model_uid: Some("gpt-5-4-mini-low"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-medium", enum_value: 0, model_uid: Some("gpt-5-4-mini-medium"), credit_multiplier: 1.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-high", enum_value: 0, model_uid: Some("gpt-5-4-mini-high"), credit_multiplier: 4.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.4-mini-xhigh", enum_value: 0, model_uid: Some("gpt-5-4-mini-xhigh"), credit_multiplier: 12.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5", enum_value: 0, model_uid: Some("gpt-5-5-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-none", enum_value: 0, model_uid: Some("gpt-5-5-none"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-low", enum_value: 0, model_uid: Some("gpt-5-5-low"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-medium", enum_value: 0, model_uid: Some("gpt-5-5-medium"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-high", enum_value: 0, model_uid: Some("gpt-5-5-high"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-xhigh", enum_value: 0, model_uid: Some("gpt-5-5-xhigh"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-none-fast", enum_value: 0, model_uid: Some("gpt-5-5-none-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-low-fast", enum_value: 0, model_uid: Some("gpt-5-5-low-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-medium-fast", enum_value: 0, model_uid: Some("gpt-5-5-medium-priority"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-high-fast", enum_value: 0, model_uid: Some("gpt-5-5-high-priority"), credit_multiplier: 8.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.5-xhigh-fast", enum_value: 0, model_uid: Some("gpt-5-5-xhigh-priority"), credit_multiplier: 16.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-low", enum_value: 0, model_uid: Some("gpt-5-3-codex-low"), credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-high", enum_value: 0, model_uid: Some("gpt-5-3-codex-high"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-xhigh", enum_value: 0, model_uid: Some("gpt-5-3-codex-xhigh"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-low-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-low-priority"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-medium-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-medium-priority"), credit_multiplier: 2.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-high-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-high-priority"), credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-5.3-codex-xhigh-fast", enum_value: 0, model_uid: Some("gpt-5-3-codex-xhigh-priority"), credit_multiplier: 6.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gpt-oss-120b", enum_value: 0, model_uid: Some("MODEL_GPT_OSS_120B"), credit_multiplier: 0.25, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-mini", enum_value: 207, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3", enum_value: 218, model_uid: Some("MODEL_CHAT_O3"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-high", enum_value: 0, model_uid: Some("MODEL_CHAT_O3_HIGH"), credit_multiplier: 1.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o3-pro", enum_value: 294, model_uid: None, credit_multiplier: 4.0, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "o4-mini", enum_value: 264, model_uid: None, credit_multiplier: 0.5, provider: "openai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-2.5-pro", enum_value: 246, model_uid: Some("MODEL_GOOGLE_GEMINI_2_5_PRO"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-2.5-flash", enum_value: 312, model_uid: Some("MODEL_GOOGLE_GEMINI_2_5_FLASH"), credit_multiplier: 0.5, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-pro", enum_value: 412, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_PRO_LOW"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-minimal", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL"), credit_multiplier: 0.75, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-low", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash", enum_value: 415, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.0-flash-high", enum_value: 0, model_uid: Some("MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH"), credit_multiplier: 1.75, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.1-pro-low", enum_value: 0, model_uid: Some("gemini-3-1-pro-low"), credit_multiplier: 1.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "gemini-3.1-pro-high", enum_value: 0, model_uid: Some("gemini-3-1-pro-high"), credit_multiplier: 2.0, provider: "google", deprecated: false },
|
||||
WindsurfModel { canonical_name: "deepseek-v3", enum_value: 205, model_uid: None, credit_multiplier: 0.5, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "deepseek-v3-2", enum_value: 409, model_uid: None, credit_multiplier: 0.5, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "deepseek-r1", enum_value: 206, model_uid: None, credit_multiplier: 1.0, provider: "deepseek", deprecated: true },
|
||||
WindsurfModel { canonical_name: "grok-3", enum_value: 217, model_uid: Some("MODEL_XAI_GROK_3"), credit_multiplier: 1.0, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "grok-3-mini", enum_value: 234, model_uid: None, credit_multiplier: 0.5, provider: "xai", deprecated: true },
|
||||
WindsurfModel { canonical_name: "grok-3-mini-thinking", enum_value: 0, model_uid: Some("MODEL_XAI_GROK_3_MINI_REASONING"), credit_multiplier: 0.125, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "grok-code-fast-1", enum_value: 0, model_uid: Some("MODEL_PRIVATE_4"), credit_multiplier: 0.5, provider: "xai", deprecated: false },
|
||||
WindsurfModel { canonical_name: "qwen-3", enum_value: 324, model_uid: None, credit_multiplier: 0.5, provider: "alibaba", deprecated: true },
|
||||
WindsurfModel { canonical_name: "kimi-k2", enum_value: 323, model_uid: Some("MODEL_KIMI_K2"), credit_multiplier: 0.5, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2-thinking", enum_value: 394, model_uid: Some("MODEL_KIMI_K2_THINKING"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2.5", enum_value: 0, model_uid: Some("kimi-k2-5"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "kimi-k2-6", enum_value: 0, model_uid: Some("kimi-k2-6"), credit_multiplier: 1.0, provider: "moonshot", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-4.7", enum_value: 417, model_uid: Some("MODEL_GLM_4_7"), credit_multiplier: 0.25, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-4.7-fast", enum_value: 418, model_uid: Some("MODEL_GLM_4_7_FAST"), credit_multiplier: 0.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-5", enum_value: 0, model_uid: Some("glm-5"), credit_multiplier: 1.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "glm-5.1", enum_value: 0, model_uid: Some("glm-5-1"), credit_multiplier: 1.5, provider: "zhipu", deprecated: false },
|
||||
WindsurfModel { canonical_name: "minimax-m2.5", enum_value: 419, model_uid: Some("MODEL_MINIMAX_M2_1"), credit_multiplier: 1.0, provider: "minimax", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5", enum_value: 377, model_uid: Some("MODEL_SWE_1_5_SLOW"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5-fast", enum_value: 359, model_uid: Some("MODEL_SWE_1_5"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.5-thinking", enum_value: 369, model_uid: Some("MODEL_SWE_1_5_THINKING"), credit_multiplier: 0.75, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.6", enum_value: 420, model_uid: Some("MODEL_SWE_1_6"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "swe-1.6-fast", enum_value: 421, model_uid: Some("MODEL_SWE_1_6_FAST"), credit_multiplier: 0.5, provider: "windsurf", deprecated: false },
|
||||
WindsurfModel { canonical_name: "adaptive", enum_value: 0, model_uid: Some("adaptive"), credit_multiplier: 1.0, provider: "windsurf", deprecated: true },
|
||||
WindsurfModel { canonical_name: "arena-fast", enum_value: 0, model_uid: Some("arena-fast"), credit_multiplier: 0.5, provider: "windsurf", deprecated: true },
|
||||
WindsurfModel { canonical_name: "arena-smart", enum_value: 0, model_uid: Some("arena-smart"), credit_multiplier: 1.0, provider: "windsurf", deprecated: true },
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
const ALIASES: &[(&str, &str)] = &[
|
||||
("claude-3-5-haiku-20241022", "claude-4.5-haiku"),
|
||||
("claude-3-5-haiku-latest", "claude-4.5-haiku"),
|
||||
("claude-3-5-sonnet-20240620", "claude-3.5-sonnet"),
|
||||
("claude-3-5-sonnet-20241022", "claude-3.5-sonnet"),
|
||||
("claude-3-5-sonnet-latest", "claude-3.5-sonnet"),
|
||||
("claude-3-7-sonnet-20250219", "claude-3.7-sonnet"),
|
||||
("claude-3-7-sonnet-latest", "claude-3.7-sonnet"),
|
||||
("claude-4.6", "claude-sonnet-4.6"),
|
||||
("claude-4.6-1m", "claude-sonnet-4.6-1m"),
|
||||
("claude-4.6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("claude-4.6-thinking-1m", "claude-sonnet-4.6-thinking-1m"),
|
||||
("claude-haiku-3-5", "claude-4.5-haiku"),
|
||||
("claude-haiku-3-5-latest", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5-20251001", "claude-4.5-haiku"),
|
||||
("claude-haiku-4-5-latest", "claude-4.5-haiku"),
|
||||
("claude-haiku-4.5", "claude-4.5-haiku"),
|
||||
("claude-haiku-4.5-latest", "claude-4.5-haiku"),
|
||||
("claude-opus-4-0", "claude-4-opus"),
|
||||
("claude-opus-4-1", "claude-4.1-opus"),
|
||||
("claude-opus-4-1-20250805", "claude-4.1-opus"),
|
||||
("claude-opus-4-20250514", "claude-4-opus"),
|
||||
("claude-opus-4-5", "claude-4.5-opus"),
|
||||
("claude-opus-4-5-20251101", "claude-4.5-opus"),
|
||||
("claude-opus-4-5-latest", "claude-4.5-opus"),
|
||||
("claude-opus-4-6", "claude-opus-4.6"),
|
||||
("claude-opus-4-6-thinking", "claude-opus-4.6-thinking"),
|
||||
("claude-opus-4-7", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4-7-latest", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4-7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.5", "claude-4.5-opus"),
|
||||
("claude-opus-4.5-thinking", "claude-4.5-opus-thinking"),
|
||||
("claude-opus-4.7", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4.7-high", "claude-opus-4-7-high"),
|
||||
("claude-opus-4.7-high-thinking", "claude-opus-4-7-high-thinking"),
|
||||
("claude-opus-4.7-low", "claude-opus-4-7-low"),
|
||||
("claude-opus-4.7-max", "claude-opus-4-7-max"),
|
||||
("claude-opus-4.7-medium", "claude-opus-4-7-medium"),
|
||||
("claude-opus-4.7-medium-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("claude-opus-4.7-xhigh", "claude-opus-4-7-xhigh"),
|
||||
("claude-opus-4.7-xhigh-thinking", "claude-opus-4-7-xhigh-thinking"),
|
||||
("claude-sonnet-4-0", "claude-4-sonnet"),
|
||||
("claude-sonnet-4-20250514", "claude-4-sonnet"),
|
||||
("claude-sonnet-4-5", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-5-20250929", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-5-latest", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4-6", "claude-sonnet-4.6"),
|
||||
("claude-sonnet-4-6-1m", "claude-sonnet-4.6-1m"),
|
||||
("claude-sonnet-4-6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("claude-sonnet-4-6-thinking-1m", "claude-sonnet-4.6-thinking-1m"),
|
||||
("claude-sonnet-4.5", "claude-4.5-sonnet"),
|
||||
("claude-sonnet-4.5-thinking", "claude-4.5-sonnet-thinking"),
|
||||
("gpt-4.1-2025-04-14", "gpt-4.1"),
|
||||
("gpt-4.1-mini-2025-04-14", "gpt-4.1-mini"),
|
||||
("gpt-4.1-nano-2025-04-14", "gpt-4.1-nano"),
|
||||
("gpt-4o-2024-05-13", "gpt-4o"),
|
||||
("gpt-4o-2024-08-06", "gpt-4o"),
|
||||
("gpt-4o-2024-11-20", "gpt-4o"),
|
||||
("gpt-4o-mini-2024-07-18", "gpt-4o-mini"),
|
||||
("gpt-5-2-codex-medium", "gpt-5.2-codex-medium"),
|
||||
("gpt-5-2-medium", "gpt-5.2"),
|
||||
("gpt-5-2025-08-07", "gpt-5"),
|
||||
("gpt-5-3-codex-high", "gpt-5.3-codex-high"),
|
||||
("gpt-5-3-codex-high-priority", "gpt-5.3-codex-high-fast"),
|
||||
("gpt-5-3-codex-low", "gpt-5.3-codex-low"),
|
||||
("gpt-5-3-codex-low-priority", "gpt-5.3-codex-low-fast"),
|
||||
("gpt-5-3-codex-medium", "gpt-5.3-codex"),
|
||||
("gpt-5-3-codex-medium-priority", "gpt-5.3-codex-medium-fast"),
|
||||
("gpt-5-3-codex-xhigh", "gpt-5.3-codex-xhigh"),
|
||||
("gpt-5-3-codex-xhigh-priority", "gpt-5.3-codex-xhigh-fast"),
|
||||
("gpt-5-4-high", "gpt-5.4-high"),
|
||||
("gpt-5-4-low", "gpt-5.4-low"),
|
||||
("gpt-5-4-medium", "gpt-5.4-medium"),
|
||||
("gpt-5-4-mini-high", "gpt-5.4-mini-high"),
|
||||
("gpt-5-4-mini-low", "gpt-5.4-mini-low"),
|
||||
("gpt-5-4-mini-medium", "gpt-5.4-mini-medium"),
|
||||
("gpt-5-4-mini-xhigh", "gpt-5.4-mini-xhigh"),
|
||||
("gpt-5-4-none", "gpt-5.4-none"),
|
||||
("gpt-5-4-xhigh", "gpt-5.4-xhigh"),
|
||||
("gpt-5-5", "gpt-5.5-medium"),
|
||||
("gpt-5-5-high", "gpt-5.5-high"),
|
||||
("gpt-5-5-high-priority", "gpt-5.5-high-fast"),
|
||||
("gpt-5-5-low", "gpt-5.5-low"),
|
||||
("gpt-5-5-low-priority", "gpt-5.5-low-fast"),
|
||||
("gpt-5-5-medium", "gpt-5.5-medium"),
|
||||
("gpt-5-5-medium-priority", "gpt-5.5-medium-fast"),
|
||||
("gpt-5-5-none", "gpt-5.5-none"),
|
||||
("gpt-5-5-none-priority", "gpt-5.5-none-fast"),
|
||||
("gpt-5-5-xhigh", "gpt-5.5-xhigh"),
|
||||
("gpt-5-5-xhigh-priority", "gpt-5.5-xhigh-fast"),
|
||||
("gpt-5-pro-2025-10-06", "gpt-5-high"),
|
||||
("gpt-5.2-codex", "gpt-5.2-codex-medium"),
|
||||
("gpt-5.2-medium", "gpt-5.2"),
|
||||
("gpt-5.3-codex-medium", "gpt-5.3-codex"),
|
||||
("gpt-5.4", "gpt-5.4-medium"),
|
||||
("gpt-5.5", "gpt-5.5-medium"),
|
||||
("haiku-4.5", "claude-4.5-haiku"),
|
||||
("kimi-k2-5", "kimi-k2.5"),
|
||||
("minimax-m2-5", "minimax-m2.5"),
|
||||
("model_claude_4_5_sonnet", "claude-4.5-sonnet"),
|
||||
("model_claude_4_5_sonnet_thinking", "claude-4.5-sonnet-thinking"),
|
||||
("o4.7", "claude-opus-4-7-medium"),
|
||||
("opus-4", "claude-4-opus"),
|
||||
("opus-4-7", "claude-opus-4-7-medium"),
|
||||
("opus-4.1", "claude-4.1-opus"),
|
||||
("opus-4.6", "claude-opus-4.6"),
|
||||
("opus-4.6-thinking", "claude-opus-4.6-thinking"),
|
||||
("opus-4.7", "claude-opus-4-7-medium"),
|
||||
("opus-4.7-thinking", "claude-opus-4-7-medium-thinking"),
|
||||
("sonnet-3.5", "claude-3.5-sonnet"),
|
||||
("sonnet-3.7", "claude-3.7-sonnet"),
|
||||
("sonnet-4", "claude-4-sonnet"),
|
||||
("sonnet-4.5", "claude-4.5-sonnet"),
|
||||
("sonnet-4.5-thinking", "claude-4.5-sonnet-thinking"),
|
||||
("sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("sonnet-4.6-1m", "claude-sonnet-4.6-1m"),
|
||||
("sonnet-4.6-thinking", "claude-sonnet-4.6-thinking"),
|
||||
("swe-1-6", "swe-1.6"),
|
||||
("swe-1-6-fast", "swe-1.6-fast"),
|
||||
("ws-haiku", "claude-4.5-haiku"),
|
||||
("ws-opus", "claude-opus-4.6"),
|
||||
("ws-opus-thinking", "claude-opus-4.6-thinking"),
|
||||
("ws-sonnet", "claude-sonnet-4.6"),
|
||||
("ws-sonnet-thinking", "claude-sonnet-4.6-thinking"),
|
||||
];
|
||||
|
||||
pub fn windsurf_models() -> &'static [WindsurfModel] {
|
||||
MODELS
|
||||
}
|
||||
|
||||
pub fn resolve_windsurf_model(name: &str) -> Option<WindsurfModel> {
|
||||
let normalized = name.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let canonical = ALIASES
|
||||
.iter()
|
||||
.find_map(|(alias, canonical)| (*alias == normalized).then_some(*canonical))
|
||||
.unwrap_or(normalized.as_str());
|
||||
MODELS
|
||||
.iter()
|
||||
.find(|model| model_matches(model, canonical))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_matches(model: &WindsurfModel, value: &str) -> bool {
|
||||
model.canonical_name.eq_ignore_ascii_case(value)
|
||||
|| model
|
||||
.model_uid
|
||||
.is_some_and(|uid| uid.eq_ignore_ascii_case(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_gpt55_cloud_alias_to_windsurf_model_uid() {
|
||||
let model = resolve_windsurf_model("gpt-5-5-low").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "gpt-5.5-low");
|
||||
assert_eq!(model.model_uid.as_deref(), Some("gpt-5-5-low"));
|
||||
assert_eq!(model.enum_value, 0);
|
||||
assert_eq!(model.credit_multiplier, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_claude_opus_47_bare_alias_to_medium() {
|
||||
let model = resolve_windsurf_model("claude-opus-4.7").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "claude-opus-4-7-medium");
|
||||
assert_eq!(model.model_uid.as_deref(), Some("claude-opus-4-7-medium"));
|
||||
assert_eq!(model.enum_value, 0);
|
||||
assert_eq!(model.credit_multiplier, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_priority_alias_to_fast_variant() {
|
||||
let model = resolve_windsurf_model("gpt-5-5-low-priority").expect("model should resolve");
|
||||
|
||||
assert_eq!(model.canonical_name, "gpt-5.5-low-fast");
|
||||
assert_eq!(model.model_uid.as_deref(), Some("gpt-5-5-low-priority"));
|
||||
assert_eq!(model.credit_multiplier, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_full_gpt55_effort_ladder_and_priority_aliases() {
|
||||
let none = resolve_windsurf_model("gpt-5-5-none").expect("none should resolve");
|
||||
assert_eq!(none.canonical_name, "gpt-5.5-none");
|
||||
assert_eq!(none.model_uid.as_deref(), Some("gpt-5-5-none"));
|
||||
assert_eq!(none.credit_multiplier, 1.0);
|
||||
|
||||
let high = resolve_windsurf_model("gpt-5.5-high").expect("high should resolve");
|
||||
assert_eq!(high.canonical_name, "gpt-5.5-high");
|
||||
assert_eq!(high.model_uid.as_deref(), Some("gpt-5-5-high"));
|
||||
assert_eq!(high.credit_multiplier, 4.0);
|
||||
|
||||
let xhigh_fast = resolve_windsurf_model("gpt-5-5-xhigh-priority")
|
||||
.expect("xhigh priority should resolve");
|
||||
assert_eq!(xhigh_fast.canonical_name, "gpt-5.5-xhigh-fast");
|
||||
assert_eq!(
|
||||
xhigh_fast.model_uid.as_deref(),
|
||||
Some("gpt-5-5-xhigh-priority")
|
||||
);
|
||||
assert_eq!(xhigh_fast.credit_multiplier, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_windsurfapi_catalog_aliases_beyond_gpt55() {
|
||||
let gpt52_medium = resolve_windsurf_model("gpt-5.2-medium").expect("gpt-5.2 medium alias");
|
||||
assert_eq!(gpt52_medium.canonical_name, "gpt-5.2");
|
||||
assert_eq!(
|
||||
gpt52_medium.model_uid.as_deref(),
|
||||
Some("MODEL_GPT_5_2_MEDIUM")
|
||||
);
|
||||
|
||||
let haiku = resolve_windsurf_model("claude-haiku-4-5-20251001").expect("dated haiku alias");
|
||||
assert_eq!(haiku.canonical_name, "claude-4.5-haiku");
|
||||
assert_eq!(haiku.model_uid.as_deref(), Some("MODEL_PRIVATE_11"));
|
||||
|
||||
let uid = resolve_windsurf_model("MODEL_GPT_5_2_LOW").expect("model uid alias");
|
||||
assert_eq!(uid.canonical_name, "gpt-5.2-low");
|
||||
assert_eq!(uid.enum_value, 400);
|
||||
|
||||
let cursor = resolve_windsurf_model("ws-opus").expect("cursor alias");
|
||||
assert_eq!(cursor.canonical_name, "claude-opus-4.6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_catalog_covers_current_windsurfapi_model_set() {
|
||||
assert_eq!(MODELS.len(), 139);
|
||||
assert!(ALIASES.len() >= 100);
|
||||
}
|
||||
}
|
||||
248
crates/aether-provider-transport/src/windsurf/proto.rs
Normal file
248
crates/aether-provider-transport/src/windsurf/proto.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WireType {
|
||||
Varint = 0,
|
||||
Fixed64 = 1,
|
||||
Len = 2,
|
||||
Fixed32 = 5,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FieldValue {
|
||||
Varint(u64),
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Field {
|
||||
pub number: u32,
|
||||
pub wire_type: WireType,
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
match &self.value {
|
||||
FieldValue::Bytes(bytes) => bytes.as_slice(),
|
||||
FieldValue::Varint(_) => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProtoError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ProtoError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProtoError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProtoError {}
|
||||
|
||||
pub fn encode_varint(value: u64) -> Vec<u8> {
|
||||
let mut value = value;
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let mut byte = (value & 0x7f) as u8;
|
||||
value >>= 7;
|
||||
if value != 0 {
|
||||
byte |= 0x80;
|
||||
}
|
||||
out.push(byte);
|
||||
if value == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_varint(buf: &[u8], offset: usize) -> Result<(u64, usize), ProtoError> {
|
||||
let mut value = 0u64;
|
||||
let mut shift = 0u32;
|
||||
let mut pos = offset;
|
||||
while pos < buf.len() {
|
||||
let byte = buf[pos];
|
||||
pos += 1;
|
||||
value |= u64::from(byte & 0x7f) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Ok((value, pos - offset));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
return Err(ProtoError::new("varint overflow"));
|
||||
}
|
||||
}
|
||||
Err(ProtoError::new("truncated varint"))
|
||||
}
|
||||
|
||||
fn tag(field: u32, wire_type: WireType) -> Vec<u8> {
|
||||
encode_varint((u64::from(field) << 3) | wire_type as u64)
|
||||
}
|
||||
|
||||
pub fn write_varint_field(field: u32, value: u64) -> Vec<u8> {
|
||||
let mut out = tag(field, WireType::Varint);
|
||||
out.extend(encode_varint(value));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_string_field(field: u32, value: &str) -> Vec<u8> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out = tag(field, WireType::Len);
|
||||
out.extend(encode_varint(bytes.len() as u64));
|
||||
out.extend(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_message_field(field: u32, value: &[u8]) -> Vec<u8> {
|
||||
if value.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = tag(field, WireType::Len);
|
||||
out.extend(encode_varint(value.len() as u64));
|
||||
out.extend(value);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write_bool_field(field: u32, value: bool) -> Vec<u8> {
|
||||
if value {
|
||||
write_varint_field(field, 1)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_fields(buf: &[u8]) -> Result<Vec<Field>, ProtoError> {
|
||||
let mut fields = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while pos < buf.len() {
|
||||
let (tag, tag_len) = decode_varint(buf, pos)?;
|
||||
pos += tag_len;
|
||||
let number = (tag >> 3) as u32;
|
||||
let wire_type = match tag & 0x07 {
|
||||
0 => WireType::Varint,
|
||||
1 => WireType::Fixed64,
|
||||
2 => WireType::Len,
|
||||
5 => WireType::Fixed32,
|
||||
other => {
|
||||
return Err(ProtoError::new(format!(
|
||||
"unknown wire type {other} at offset {pos}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let value = match wire_type {
|
||||
WireType::Varint => {
|
||||
let (value, value_len) = decode_varint(buf, pos)?;
|
||||
pos += value_len;
|
||||
FieldValue::Varint(value)
|
||||
}
|
||||
WireType::Len => {
|
||||
let (len, len_len) = decode_varint(buf, pos)?;
|
||||
pos += len_len;
|
||||
let len = usize::try_from(len)
|
||||
.map_err(|_| ProtoError::new("length-delimited field too large"))?;
|
||||
if pos + len > buf.len() {
|
||||
return Err(ProtoError::new(format!(
|
||||
"truncated len-delimited field {number} at offset {pos}"
|
||||
)));
|
||||
}
|
||||
let bytes = buf[pos..pos + len].to_vec();
|
||||
pos += len;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
WireType::Fixed64 => {
|
||||
if pos + 8 > buf.len() {
|
||||
return Err(ProtoError::new(format!("truncated fixed64 field {number}")));
|
||||
}
|
||||
let bytes = buf[pos..pos + 8].to_vec();
|
||||
pos += 8;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
WireType::Fixed32 => {
|
||||
if pos + 4 > buf.len() {
|
||||
return Err(ProtoError::new(format!("truncated fixed32 field {number}")));
|
||||
}
|
||||
let bytes = buf[pos..pos + 4].to_vec();
|
||||
pos += 4;
|
||||
FieldValue::Bytes(bytes)
|
||||
}
|
||||
};
|
||||
fields.push(Field {
|
||||
number,
|
||||
wire_type,
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
pub fn get_field(fields: &[Field], number: u32, wire_type: Option<WireType>) -> Option<&Field> {
|
||||
fields
|
||||
.iter()
|
||||
.find(|field| field.number == number && wire_type.is_none_or(|ty| field.wire_type == ty))
|
||||
}
|
||||
|
||||
pub fn get_all_fields(fields: &[Field], number: u32) -> Vec<&Field> {
|
||||
fields
|
||||
.iter()
|
||||
.filter(|field| field.number == number)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_varint(fields: &[Field], number: u32) -> Option<u64> {
|
||||
match get_field(fields, number, Some(WireType::Varint))?.value {
|
||||
FieldValue::Varint(value) => Some(value),
|
||||
FieldValue::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_string(fields: &[Field], number: u32) -> Option<String> {
|
||||
let field = get_field(fields, number, Some(WireType::Len))?;
|
||||
String::from_utf8(field.bytes().to_vec()).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_varint_and_string_fields_like_windsurfapi() {
|
||||
assert_eq!(encode_varint(300), vec![0xac, 0x02]);
|
||||
assert_eq!(
|
||||
write_string_field(3, "abc"),
|
||||
vec![0x1a, 0x03, b'a', b'b', b'c']
|
||||
);
|
||||
assert_eq!(write_bool_field(2, false), Vec::<u8>::new());
|
||||
assert_eq!(write_bool_field(2, true), vec![0x10, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_repeated_len_delimited_fields() {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend(write_string_field(1, "alpha"));
|
||||
bytes.extend(write_string_field(1, "beta"));
|
||||
bytes.extend(write_varint_field(2, 42));
|
||||
|
||||
let fields = parse_fields(&bytes).expect("fields should parse");
|
||||
assert_eq!(get_all_fields(&fields, 1).len(), 2);
|
||||
assert_eq!(get_string(&fields, 1).as_deref(), Some("alpha"));
|
||||
assert_eq!(get_varint(&fields, 2), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_len_delimited_field() {
|
||||
let err = parse_fields(&[0x0a, 0x05, b'a']).expect_err("must reject truncated field");
|
||||
assert!(err.to_string().contains("truncated"));
|
||||
}
|
||||
}
|
||||
@@ -153,20 +153,16 @@ pub fn resolve_provider_model_name_with_model_directives(
|
||||
return None;
|
||||
}
|
||||
|
||||
if key_allowed_models
|
||||
.iter()
|
||||
.any(|value| value == requested_model_name)
|
||||
for candidate_name in
|
||||
requested_model_name_candidates(requested_model_name, enable_model_directives)
|
||||
{
|
||||
return Some((selected_provider_model_name, None));
|
||||
}
|
||||
|
||||
if enable_model_directives {
|
||||
if let Some(base_model) =
|
||||
aether_ai_formats::model_directive_base_model(requested_model_name)
|
||||
if key_allowed_models
|
||||
.iter()
|
||||
.any(|value| value == candidate_name.as_ref())
|
||||
{
|
||||
if key_allowed_models.iter().any(|value| value == &base_model) {
|
||||
return Some((selected_provider_model_name, Some(base_model)));
|
||||
}
|
||||
let matched = (candidate_name.as_ref() != requested_model_name)
|
||||
.then(|| candidate_name.into_owned());
|
||||
return Some((selected_provider_model_name, matched));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,10 +424,55 @@ fn requested_model_name_candidates(
|
||||
enable_model_directives: bool,
|
||||
) -> impl Iterator<Item = Cow<'_, str>> {
|
||||
let requested_model_name = requested_model_name.trim();
|
||||
let base_model = enable_model_directives
|
||||
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
|
||||
.flatten();
|
||||
std::iter::once(Cow::Borrowed(requested_model_name)).chain(base_model.map(Cow::Owned))
|
||||
let mut candidates = Vec::new();
|
||||
push_model_name_candidate(&mut candidates, Cow::Borrowed(requested_model_name));
|
||||
for alias in requested_model_name_aliases(requested_model_name) {
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(alias));
|
||||
}
|
||||
if enable_model_directives {
|
||||
if let Some(base_model) =
|
||||
aether_ai_formats::model_directive_base_model(requested_model_name)
|
||||
{
|
||||
for alias in requested_model_name_aliases(&base_model) {
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(alias));
|
||||
}
|
||||
push_model_name_candidate(&mut candidates, Cow::Owned(base_model));
|
||||
}
|
||||
}
|
||||
candidates.into_iter()
|
||||
}
|
||||
|
||||
fn push_model_name_candidate<'a>(candidates: &mut Vec<Cow<'a, str>>, candidate: Cow<'a, str>) {
|
||||
if candidate.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|existing| existing.as_ref() == candidate.as_ref())
|
||||
{
|
||||
return;
|
||||
}
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
fn requested_model_name_aliases(requested_model_name: &str) -> Vec<String> {
|
||||
let requested_model_name = requested_model_name.trim();
|
||||
let Some(alias) = windsurf_gpt55_model_alias(requested_model_name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![alias]
|
||||
}
|
||||
|
||||
fn windsurf_gpt55_model_alias(model_name: &str) -> Option<String> {
|
||||
let suffix = model_name
|
||||
.strip_prefix("gpt-5-5")
|
||||
.map(|suffix| format!("gpt-5.5{suffix}"))
|
||||
.or_else(|| {
|
||||
model_name
|
||||
.strip_prefix("gpt-5.5")
|
||||
.map(|suffix| format!("gpt-5-5{suffix}"))
|
||||
})?;
|
||||
(suffix != model_name).then_some(suffix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -544,6 +585,39 @@ mod tests {
|
||||
assert_eq!(resolved.1.as_deref(), Some("gpt-5.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_dashed_gpt55_alias_matches_dotted_model_name() {
|
||||
let row = sample_row("gpt-5.5-low", "gpt-5.5-low");
|
||||
|
||||
assert!(row_supports_requested_model(
|
||||
&row,
|
||||
"gpt-5-5-low",
|
||||
"openai:chat"
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_requested_global_model_name_with_model_directives(
|
||||
&[row],
|
||||
"gpt-5-5-low",
|
||||
"openai:chat",
|
||||
false,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("gpt-5.5-low")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_dashed_gpt55_alias_satisfies_key_allowed_models() {
|
||||
let mut row = sample_row("gpt-5.5-low", "windsurf-upstream-uid");
|
||||
row.key_allowed_models = Some(vec!["gpt-5.5-low".to_string()]);
|
||||
|
||||
let resolved = resolve_provider_model_name(&row, "gpt-5-5-low", "openai:chat")
|
||||
.expect("dashed alias should satisfy dotted allowed model");
|
||||
|
||||
assert_eq!(resolved.0, "windsurf-upstream-uid");
|
||||
assert_eq!(resolved.1.as_deref(), Some("gpt-5.5-low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_scoped_default_mapping_limits_exact_global_model_match() {
|
||||
let mut row = sample_row("deepseek-v4-pro", "deepseek-v4-pro");
|
||||
|
||||
@@ -2684,9 +2684,31 @@ function isWindsurfUnavailableKey(key: EndpointAPIKey): boolean {
|
||||
return code === 'banned' || code === 'forbidden' || code === 'quarantined'
|
||||
}
|
||||
|
||||
function getPositiveQuotaNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function windsurfCooldownHasPositiveReset(key: EndpointAPIKey): boolean {
|
||||
const quota = getQuotaSnapshotForProvider(key, 'windsurf')
|
||||
const rateLimit = quota?.rate_limit
|
||||
if (rateLimit && typeof rateLimit === 'object') {
|
||||
const retryAfterMs =
|
||||
getPositiveQuotaNumber(rateLimit.retry_after_ms)
|
||||
?? getPositiveQuotaNumber(rateLimit.retryAfterMs)
|
||||
if (retryAfterMs !== undefined) return true
|
||||
}
|
||||
|
||||
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
|
||||
return (
|
||||
getPositiveQuotaNumber(rateLimitWindow?.reset_seconds) !== undefined
|
||||
|| getPositiveQuotaNumber(rateLimitWindow?.reset_at) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function isWindsurfExhaustedKey(key: EndpointAPIKey): boolean {
|
||||
const code = String(getQuotaSnapshotForProvider(key, 'windsurf')?.code || '').trim().toLowerCase()
|
||||
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown'
|
||||
if (code === 'cooldown') return windsurfCooldownHasPositiveReset(key)
|
||||
return code === 'exhausted' || code === 'rate_limited' || code === 'rate_limit'
|
||||
}
|
||||
|
||||
function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
|
||||
@@ -2694,7 +2716,8 @@ function getWindsurfQuotaStatusLabel(key: EndpointAPIKey): string {
|
||||
const label = quota?.label?.trim()
|
||||
if (label) return label
|
||||
const code = String(quota?.code || '').trim().toLowerCase()
|
||||
return code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown' ? '速率受限' : '额度耗尽'
|
||||
if (code === 'cooldown') return '冷却中'
|
||||
return code === 'rate_limited' || code === 'rate_limit' ? '速率受限' : '额度耗尽'
|
||||
}
|
||||
|
||||
function getWindsurfModelPreview(key: EndpointAPIKey): string | null {
|
||||
|
||||
@@ -127,6 +127,16 @@ describe('providerKeyQuota', () => {
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('冷却中')
|
||||
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'cooldown',
|
||||
exhausted: false,
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('冷却中')
|
||||
})
|
||||
|
||||
it('includes Windsurf quota windows and model availability in display text', () => {
|
||||
@@ -160,6 +170,44 @@ describe('providerKeyQuota', () => {
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('日剩余 75.0% | 周剩余 50.0% | Prompt 剩余 12/20 | Flex 剩余 3/5 | 可用模型 7 个')
|
||||
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'windsurf',
|
||||
code: 'cooldown',
|
||||
label: '冷却中',
|
||||
exhausted: false,
|
||||
rate_limit: {
|
||||
limited: true,
|
||||
has_capacity: true,
|
||||
messages_remaining: -1,
|
||||
max_messages: -1,
|
||||
},
|
||||
allowed_models_count: 118,
|
||||
windows: [
|
||||
{
|
||||
code: 'daily',
|
||||
remaining_ratio: 0.99,
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
remaining_ratio: 1,
|
||||
},
|
||||
{
|
||||
code: 'prompt',
|
||||
remaining_value: 100,
|
||||
limit_value: 100,
|
||||
},
|
||||
{
|
||||
code: 'rate_limit',
|
||||
reset_seconds: null,
|
||||
is_exhausted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'windsurf')).toBe('日剩余 99.0% | 周剩余 100.0% | Prompt 剩余 100/100 | 可用模型 118 个')
|
||||
})
|
||||
|
||||
it('uses Windsurf model availability when no quota window is present', () => {
|
||||
|
||||
@@ -76,6 +76,24 @@ function getQuotaWindow(
|
||||
return getQuotaWindows(quota).find(window => normalizeText(window.code)?.toLowerCase() === normalizedCode) ?? null
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function windsurfCooldownHasPositiveReset(quota: QuotaStatusSnapshot): boolean {
|
||||
const rateLimit = quota.rate_limit
|
||||
if (rateLimit && typeof rateLimit === 'object') {
|
||||
const retryAfterMs = positiveNumber(rateLimit.retry_after_ms) ?? positiveNumber(rateLimit.retryAfterMs)
|
||||
if (retryAfterMs != null) return true
|
||||
}
|
||||
|
||||
const rateLimitWindow = getQuotaWindow(quota, 'rate_limit')
|
||||
return (
|
||||
positiveNumber(rateLimitWindow?.reset_seconds) != null
|
||||
|| positiveNumber(rateLimitWindow?.reset_at) != null
|
||||
)
|
||||
}
|
||||
|
||||
function getQuotaWindowsByScope(
|
||||
quota: QuotaStatusSnapshot | null | undefined,
|
||||
scope: string,
|
||||
@@ -219,7 +237,10 @@ function getWindsurfQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
if (code === 'banned' || code === 'forbidden' || code === 'quarantined') {
|
||||
return normalizeText(quota.label) || '账号不可用'
|
||||
}
|
||||
if (code === 'rate_limited' || code === 'rate_limit' || code === 'cooldown') {
|
||||
if (code === 'cooldown' && windsurfCooldownHasPositiveReset(quota)) {
|
||||
return normalizeText(quota.label) || '冷却中'
|
||||
}
|
||||
if (code === 'rate_limited' || code === 'rate_limit') {
|
||||
return normalizeText(quota.label) || '速率受限'
|
||||
}
|
||||
if (code === 'exhausted') {
|
||||
@@ -258,6 +279,10 @@ function getWindsurfQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
|
||||
if (parts.length > 0) return parts.join(' | ')
|
||||
|
||||
if (code === 'cooldown') {
|
||||
return normalizeText(quota.label) || '冷却中'
|
||||
}
|
||||
|
||||
return normalizeText(quota.label)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user