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
7 changes: 4 additions & 3 deletions aimux-ffi/aimux-ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -625,8 +625,9 @@ uint64_t aimux_mock_replay_new(const char *recordings_jsonl, AimuxError *err);
aimux_stream_text); release with aimux_drop_handle.

handles: array of `len` existing model handles (e.g. from aimux_openai_new).
Unknown handles are silently dropped; the call only fails if all are
unknown (or len == 0). config_json selects the router + fallback policy:
Lookup is all-or-nothing: any unknown handle fails the call, so the router
never runs with fewer children than requested. len == 0 also fails.
config_json selects the router + fallback policy:
{ "router": "rule"|"weighted", "weights": [..], "fallback": "on_error"|"none",
"provider_name": "router", "model_id": "router" } — all optional; defaults
are rule / on_error / "router" / "router".
Expand All @@ -640,7 +641,7 @@ uint64_t aimux_router_new(const uint64_t *handles, size_t len,

reference_handles: array of `ref_len` existing handles (may be NULL/0 —
MoaModel then runs just the aggregator). aggregator: a single existing
handle (must be valid). Unknown reference handles are dropped; an unknown
handle (must be valid). Lookup is all-or-nothing: any unknown reference or
aggregator handle fails. config_json is a serialized MoaConfig (all fields
optional): { "provider_name", "model_id", "aggregator_instructions",
"strip_reference_tools", "fail_mode": "best_effort"|"fail_fast" }.
Expand Down
90 changes: 60 additions & 30 deletions aimux-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ fn get_model(handle: u64) -> Option<Arc<dyn LanguageModel>> {
}
}

/// Resolve a composite's model handles under one registry lock. This makes
/// the lookup all-or-nothing with respect to concurrent handle drops: either
/// every requested model is cloned, or the constructor fails without silently
/// changing the composite's membership.
fn get_models(handles: &[u64]) -> Option<Vec<Arc<dyn LanguageModel>>> {
let registry = registry()
.lock()
.expect("aimux-ffi: registry mutex poisoned");
handles
.iter()
.map(|handle| match registry.get(handle) {
Some(ModelHandle::Language(model)) => Some(model.clone()),
_ => None,
})
.collect()
}

/// Look up a provider by handle (RFC-0027 provider handles for list_models).
fn get_provider(handle: u64) -> Option<Arc<dyn aimux_core::provider::Provider>> {
match get_handle(handle)? {
Expand Down Expand Up @@ -3017,9 +3034,9 @@ pub extern "C" fn aimux_mock_replay_new(
/// optional; defaults are `rule` / `on_error` / `"router"` / `"router"`.
///
/// Returns a non-zero handle on success, or 0 with `err` filled: null/invalid
/// pointer, bad JSON, zero-length `handles`, or an unknown child handle
/// (which is dropped from the child list — the call only fails if **all**
/// handles are unknown).
/// pointer, bad JSON, zero-length `handles`, or any unknown child handle.
/// Child lookup is all-or-nothing; unknown children are never silently
/// dropped.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_router_new(
handles: *const u64,
Expand All @@ -3031,15 +3048,9 @@ pub extern "C" fn aimux_router_new(
return unsafe { fail_invalid_args(err) };
}
let handle_slice = unsafe { std::slice::from_raw_parts(handles, len) };
let mut models: Vec<Arc<dyn aimux_core::LanguageModel>> = Vec::with_capacity(len);
for &h in handle_slice {
if let Some(m) = get_model(h) {
models.push(m);
}
}
if models.is_empty() {
return unsafe { fail_other(err, "router: no valid child handles") };
}
let Some(models) = get_models(handle_slice) else {
return unsafe { fail_other(err, "router: invalid child handle") };
};
// NULL / empty / "null" all mean "defaults" (matching parse_provider_options
// convention). Invalid UTF-8 → invalid args.
let Some(config_json) = normalize_config_json(config_json, err) else {
Expand Down Expand Up @@ -3084,8 +3095,9 @@ pub extern "C" fn aimux_router_new(
/// "fail_mode": "best_effort" | "fail_fast" }`.
///
/// Returns a non-zero handle on success, or 0 with `err` filled: null/invalid
/// pointer, bad JSON, an unknown aggregator handle, or all-unknown references
/// (references may be empty; aggregator may not).
/// pointer, bad JSON, an unknown aggregator handle, or any unknown reference
/// handle (references may be empty; aggregator may not). Model lookup is
/// all-or-nothing.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_moa_new(
reference_handles: *const u64,
Expand All @@ -3094,22 +3106,21 @@ pub extern "C" fn aimux_moa_new(
config_json: *const c_char,
err: *mut CAimuxError,
) -> u64 {
let Some(aggregator_model) = get_model(aggregator) else {
return unsafe { fail_other(err, "moa: invalid aggregator handle") };
};
let references: Vec<Arc<dyn aimux_core::LanguageModel>> =
if reference_handles.is_null() || ref_len == 0 {
Vec::new()
} else {
let slice = unsafe { std::slice::from_raw_parts(reference_handles, ref_len) };
let mut v = Vec::with_capacity(ref_len);
for &h in slice {
if let Some(m) = get_model(h) {
v.push(m);
}
}
v
};
let reference_slice: &[u64] = if ref_len == 0 {
&[]
} else if reference_handles.is_null() {
return unsafe { fail_invalid_args(err) };
} else {
unsafe { std::slice::from_raw_parts(reference_handles, ref_len) }
};
let mut handles = Vec::with_capacity(ref_len + 1);
handles.extend_from_slice(reference_slice);
handles.push(aggregator);
let Some(mut models) = get_models(&handles) else {
return unsafe { fail_other(err, "moa: invalid reference or aggregator handle") };
};
let aggregator_model = models.pop().expect("aggregator handle is always present");
let references = models;
// NULL / empty / "null" all mean "defaults" (matching parse_provider_options
// convention). Invalid UTF-8 → invalid args.
let Some(config_json) = normalize_config_json(config_json, err) else {
Expand Down Expand Up @@ -3439,6 +3450,15 @@ mod tests {
assert_eq!(err.code, AIMUX_E_INVALID_ARGUMENT);
}

#[test]
fn router_new_rejects_any_invalid_child() {
let handles = [mock_handle("mock", "valid", "ok"), 999_999];
let mut err = zero_err();
let h = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut err);
assert_eq!(h, 0, "router must not silently drop an invalid child");
assert_eq!(err.code, AIMUX_E_OTHER);
}

#[test]
fn router_new_rejects_bad_json() {
let handles = [mock_handle("mock", "m", "x")];
Expand Down Expand Up @@ -3505,6 +3525,16 @@ mod tests {
assert_eq!(err.code, AIMUX_E_OTHER);
}

#[test]
fn moa_new_rejects_any_invalid_reference() {
let refs = [mock_handle("mock", "ref-a", "A"), 999_999];
let agg = mock_handle("mock", "aggregator", "agg");
let mut err = zero_err();
let h = aimux_moa_new(refs.as_ptr(), refs.len(), agg, std::ptr::null(), &mut err);
assert_eq!(h, 0, "MoA must not silently drop an invalid reference");
assert_eq!(err.code, AIMUX_E_OTHER);
}

#[test]
fn moa_new_allows_zero_references() {
// 0 references is valid (degrades to aggregator-only).
Expand Down
Loading
Loading