Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions aimux-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,18 +328,23 @@ impl AiMuxError {
return None;
};
let headers = detail.response_headers.as_ref()?;
// Rust's float parser accepts `inf`, `Infinity` and `NaN`. A
// non-finite value is no hint: `as i64` would saturate it to
// `i64::MAX`, and upstream never uses it either (it fails the
// `ms < 60000 || ms < backoff` window).
headers
.get("retry-after-ms")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| !value.is_nan())
.filter(|value| value.is_finite())
.map(|value| value as i64)
.or_else(|| {
headers.get("retry-after").and_then(|value| {
value
.parse::<f64>()
.ok()
.filter(|value| !value.is_nan())
.map(|seconds| (seconds * 1_000.0) as i64)
.map(|seconds| seconds * 1_000.0)
.filter(|milliseconds| milliseconds.is_finite())
.map(|milliseconds| milliseconds as i64)
.or_else(|| {
httpdate::parse_http_date(value).ok().map(|date| {
match date.duration_since(SystemTime::now()) {
Expand Down Expand Up @@ -473,6 +478,37 @@ mod tests {
assert_eq!(serde_json::to_string(&error).unwrap(), json);
}

fn hint_from(name: &str, value: &str) -> Option<i64> {
AiMuxError::ApiCall(Box::new(ApiCallError {
response_headers: Some(HashMap::from([(name.into(), value.into())])),
..api_error("rate limited")
}))
.retry_after_hint()
}

/// `str::parse::<f64>` accepts `inf`, `Infinity` and `NaN`; none of them
/// is a delay, so the hint is absent instead of `i64::MAX`.
#[test]
fn non_finite_retry_after_is_no_hint() {
for value in ["Infinity", "inf", "-Infinity", "NaN"] {
assert_eq!(
hint_from("retry-after-ms", value),
None,
"retry-after-ms: {value}"
);
assert_eq!(
hint_from("retry-after", value),
None,
"retry-after: {value}"
);
}
// Finite seconds that overflow once converted to milliseconds.
assert_eq!(hint_from("retry-after", "1e308"), None);
// Finite values keep working.
assert_eq!(hint_from("retry-after-ms", "250"), Some(250));
assert_eq!(hint_from("retry-after", "1.5"), Some(1_500));
}

/// A 429 is retryable and carries its hint wherever it lives — the
/// classification is the `status_code` field, not a variant.
#[test]
Expand Down
8 changes: 8 additions & 0 deletions aimux-core/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,9 @@ pub fn new_call_id() -> String {
/// 精确匹配常见 bearer-token 字段和 AWS Bedrock sigv4 的
/// `x-amz-security-token`(name 已 lowercase 归一化)。其余 needle 维持 contains,以覆盖
/// `x-goog-api-key`/`proxy-authorization` 等变体。
///
/// Provider auth headers whose names match none of these are listed by name:
/// Prodia's `X-Prodia-Key` and the Azure `Ocp-Apim-Subscription-Key`.
#[must_use]
pub fn is_sensitive_key(name: &str) -> bool {
let n = name.to_ascii_lowercase();
Expand All @@ -583,6 +586,8 @@ pub fn is_sensitive_key(name: &str) -> bool {
n == "cookie"
|| n == "set-cookie"
|| n == "key"
|| squashed == "xprodiakey"
|| squashed.ends_with("subscriptionkey")
|| squashed.ends_with("token")
|| n.contains("authorization")
|| squashed.contains("apikey")
Expand Down Expand Up @@ -1925,6 +1930,9 @@ mod tests {
"Authorization",
"x-api-key",
"apiKey",
"X-Prodia-Key",
"Ocp-Apim-Subscription-Key",
"ocp_apim_subscription_key",
] {
assert!(is_sensitive_key(key), "{key} must be redacted");
}
Expand Down
10 changes: 10 additions & 0 deletions aimux-ffi/aimux-ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,16 @@ aimux_error_t *aimux_apply_tool_call_repair_to_result(const char *result_json,
*/
void aimux_drop_handle(uint64_t handle);

/**
* aimux_drop_handle with the handle passed as a pointer-sized value, for a
* finalizer that can only call `void (*)(void *)` (Dart NativeFinalizer).
* The pointer is never dereferenced: its address is the handle. Safe to call
* with NULL or an unknown handle (no-op).
*
* @param handle Handle from aimux_*_new, cast to a pointer.
*/
void aimux_drop_handle_ptr(void *handle);

/**
* Free a string written by any aimux function (`*out_json`, error getters).
* (Handles are released with aimux_drop_handle, errors with
Expand Down
31 changes: 31 additions & 0 deletions aimux-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2134,6 +2134,19 @@ pub extern "C" fn aimux_drop_handle(handle: u64) {
}
}

/// [`aimux_drop_handle`] with the handle passed as a pointer-sized value, for
/// callers that can only register a `void (*)(void *)` callback, such as a
/// Dart `NativeFinalizer`. The pointer is never dereferenced: its address is
/// the handle. Calling `aimux_drop_handle` through a `void (*)(void *)`
/// pointer instead passes a garbage high word on 32-bit targets, where a
/// `uint64_t` argument does not travel like a pointer.
///
/// Safe to call with NULL or an unknown handle (no-op).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_drop_handle_ptr(handle: *mut c_void) {
aimux_drop_handle(handle.addr() as u64);
}

/// Free a C string previously returned by any aimux function that hands out
/// `*mut c_char` — result JSON, error getters.
///
Expand Down Expand Up @@ -4463,6 +4476,24 @@ mod tests {
);
}

#[test]
fn drop_handle_ptr_releases_the_handle_its_address_names() {
let handle = mock_handle("mock", "finalized", "F");
let other = mock_handle("mock", "kept", "K");
assert!(get_handle(handle).is_some());

aimux_drop_handle_ptr(std::ptr::without_provenance_mut(handle as usize));
assert!(get_handle(handle).is_none());
assert!(
get_handle(other).is_some(),
"only the named handle is released"
);

// NULL is handle 0: a no-op, like aimux_drop_handle(0).
aimux_drop_handle_ptr(std::ptr::null_mut());
aimux_drop_handle(other);
}

#[test]
fn composites_reject_any_dead_member_handle() {
// A dead handle inside the array is a caller bug: reported, never
Expand Down
22 changes: 12 additions & 10 deletions aimux-ffi/tests/exports_smoke_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
//!
//! ## Coverage
//!
//! All 129 `#[unsafe(no_mangle)]` exports in `src/lib.rs` are exercised (the
//! All 130 `#[unsafe(no_mangle)]` exports in `src/lib.rs` are exercised (the
//! constructor and utility classes in full; the session class one
//! representative call per export). [`header_and_exports_agree`] pins the
//! count against the two headers. Decision exports are exercised by
Expand All @@ -51,13 +51,13 @@ use aimux_ffi::{
aimux_azure_new_with_base, aimux_bedrock_new, aimux_bedrock_new_with_base, aimux_codex_refresh,
aimux_cohere_embedding_new, aimux_cohere_embedding_new_with_base, aimux_cohere_new,
aimux_cohere_new_with_base, aimux_cohere_reranking_new, aimux_cohere_reranking_new_with_base,
aimux_consume_stream_text, aimux_drop_handle, aimux_embed, aimux_error_available_tools,
aimux_error_code, aimux_error_free, aimux_error_message, aimux_error_model_id,
aimux_error_model_type, aimux_error_original_error, aimux_error_provider_code,
aimux_error_provider_id, aimux_error_provider_message, aimux_error_response_body,
aimux_error_retry_ms, aimux_error_retryable, aimux_error_status, aimux_error_t,
aimux_error_tool_input, aimux_error_tool_name, aimux_file_upload, aimux_free_string,
aimux_generate_object, aimux_generate_text, aimux_generate_text_as_openai,
aimux_consume_stream_text, aimux_drop_handle, aimux_drop_handle_ptr, aimux_embed,
aimux_error_available_tools, aimux_error_code, aimux_error_free, aimux_error_message,
aimux_error_model_id, aimux_error_model_type, aimux_error_original_error,
aimux_error_provider_code, aimux_error_provider_id, aimux_error_provider_message,
aimux_error_response_body, aimux_error_retry_ms, aimux_error_retryable, aimux_error_status,
aimux_error_t, aimux_error_tool_input, aimux_error_tool_name, aimux_file_upload,
aimux_free_string, aimux_generate_object, aimux_generate_text, aimux_generate_text_as_openai,
aimux_generate_text_result_as_openai, aimux_get_model_specs, aimux_google_embedding_new,
aimux_google_embedding_new_with_base, aimux_google_image_new, aimux_google_image_new_with_base,
aimux_google_video_new, aimux_google_video_new_with_base, aimux_image_generate,
Expand Down Expand Up @@ -158,7 +158,7 @@ fn header_and_exports_agree() {
exports.sort();
assert_eq!(
exports.len(),
129,
130,
"export count changed; update the headers"
);

Expand Down Expand Up @@ -550,9 +550,11 @@ fn constructor_exports_build_and_release_handles() {
assert_ne!(*handles.last().unwrap(), 0, "abort_signal_new");

// Release every handle created above (aimux_drop_handle is safe with 0).
for h in [traced, audited, router, moa] {
// The pointer-sized entry point (a finalizer's callback) releases one.
for h in [traced, audited, router] {
aimux_drop_handle(h);
}
aimux_drop_handle_ptr(std::ptr::without_provenance_mut(moa as usize));

// mock_replay_new: empty input must fail cleanly (no recordings) — the
// success path needs a real Recording, covered by replay tests elsewhere.
Expand Down
15 changes: 12 additions & 3 deletions aimux-provider-utils/src/download_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ pub(crate) fn hop_trusted_origin<'a>(
trusted_origin.filter(|origin| same_origin(current_url, origin))
}

/// The URL as an error message may show it: no query, no userinfo.
fn without_query(parsed: &url::Url) -> String {
let mut redacted = parsed.clone();
redacted.set_query(None);
redacted.to_string()
crate::http::sanitized_request_url(parsed.as_str())
}

/// Parse a host as `url::Url::host_str` yields it into an IP literal. The url
Expand Down Expand Up @@ -349,6 +348,16 @@ mod tests {
}
}

#[test]
fn rejected_url_error_omits_userinfo_and_query() {
let error = validate_download_url("ftp://user:secret@example.com/file?token=t0k")
.expect_err("ftp must be blocked");
let message = error.to_string();
assert!(message.contains("ftp://example.com/file"), "{message}");
assert!(!message.contains("secret"), "{message}");
assert!(!message.contains("t0k"), "{message}");
}

#[test]
fn resolved_address_validation_fails_closed_and_preserves_all_answers() {
let empty = validate_resolved_download_addresses("empty.example", Vec::new())
Expand Down
43 changes: 42 additions & 1 deletion aimux-provider-utils/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,12 +999,53 @@ fn request_host(url: &str) -> String {
.unwrap_or_else(|| "unknown".into())
}

/// The request URL as logs, errors and recordings show it: without the query
/// (API keys travel there) and without the `user:password@` userinfo.
pub(crate) fn sanitized_request_url(url: &str) -> String {
match url::Url::parse(url) {
Ok(mut parsed) => {
parsed.set_query(None);
// Both fail only for URLs that cannot carry userinfo at all.
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.to_string()
}
Err(_) => url.split('?').next().unwrap_or(url).to_owned(),
Err(_) => {
let without_query = url.split('?').next().unwrap_or(url);
// Unparseable: drop anything up to the last '@' of the authority.
if let Some((scheme, rest)) = without_query.split_once("://") {
let authority = &rest[..rest.find('/').unwrap_or(rest.len())];
if let Some(at) = authority.rfind('@') {
return format!("{scheme}://{}", &rest[at + 1..]);
}
}
without_query.to_owned()
}
}
}

#[cfg(test)]
mod tests {
use super::sanitized_request_url;

#[test]
fn sanitized_request_url_drops_userinfo_and_query() {
assert_eq!(
sanitized_request_url("https://user:secret@api.example.com/v1/chat?key=sk-1"),
"https://api.example.com/v1/chat"
);
assert_eq!(
sanitized_request_url("https://token@api.example.com/v1"),
"https://api.example.com/v1"
);
// Unparseable (space in the host): userinfo is still dropped.
assert_eq!(
sanitized_request_url("https://user:secret@bad host/v1?key=sk-1"),
"https://bad host/v1"
);
assert_eq!(
sanitized_request_url("https://api.example.com/v1/chat?key=sk-1"),
"https://api.example.com/v1/chat"
);
}
}
53 changes: 53 additions & 0 deletions aimux-provider-utils/tests/http_recording_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,59 @@ async fn one_helper_call_records_one_finalized_exchange() {
let _ = std::fs::remove_dir_all(directory);
}

/// Provider auth headers whose names carry no `token` / `api-key` (Prodia,
/// Azure Speech) are stored as `[REDACTED]`, like `authorization`.
#[tokio::test]
#[serial]
async fn provider_auth_headers_are_recorded_redacted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/call"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let (recorder, directory) = recorder("provider-auth-headers");
recording::init_recording(Some(recorder.clone()));

let mut request = request(
format!("{}/call", server.uri()),
"call-auth",
recorder.clone(),
);
request
.headers
.push(("X-Prodia-Key".into(), "prodia-secret".into()));
request
.headers
.push(("Ocp-Apim-Subscription-Key".into(), "azure-secret".into()));
post_json_to_api(
request,
json!({"prompt": "hi"}),
create_json_response_handler::<Reply>(),
failed(),
)
.await
.unwrap();
finish_call(recorder.as_ref(), "call-auth");

let recording = read_recording(&directory);
let headers = &recording.exchanges[0].request.headers;
for name in ["X-Prodia-Key", "Ocp-Apim-Subscription-Key"] {
let (_, value) = headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.unwrap_or_else(|| panic!("{name} missing from the recording"));
assert_eq!(value, "[REDACTED]", "{name}");
}
let stored = std::fs::read_to_string(directory.join("recordings.jsonl")).unwrap();
assert!(!stored.contains("prodia-secret"), "{stored}");
assert!(!stored.contains("azure-secret"), "{stored}");

recording::init_recording(None);
let _ = std::fs::remove_dir_all(directory);
}

#[tokio::test]
#[serial]
async fn cancelled_exchange_is_recorded_as_finalized_failure() {
Expand Down
Loading
Loading