Skip to main content

aimux_ffi/
lib.rs

1//! aimux-ffi: C ABI for multi-language bindings.
2//!
3//! Provides an opaque handle registry + JSON wire format + push callback
4//! stream. The C ABI is consumed by C/C++, Go, Kotlin, Java, Swift, and
5//! Flutter. Native bindings (Python / Node) bypass this layer and use
6//! `aimux-providers` directly.
7//!
8//! ## Errors
9//!
10//! Every fallible C function returns `*mut aimux_error_t`: NULL on
11//! success (the result is written to the out-parameter), a heap-owned error
12//! value on failure (the out-parameter is left at its sentinel: handle 0,
13//! pointer NULL). Every non-NULL error has one code from [`aimux_error_code`]
14//! and one message from [`aimux_error_message`], and is released exactly once
15//! with [`aimux_error_free`]. Codes 1..17 come from `AiMuxError`, 100..105
16//! from `RecordingError`, and 200..206 identify failures detected while
17//! crossing the C ABI.
18//!
19//! ## Memory ownership
20//!
21//! - Every function returning `*mut c_char` — error getters included —
22//!   transfers ownership to the caller, who MUST free it with
23//!   [`aimux_free_string`]. Handles (`u64`) are released with
24//!   [`aimux_drop_handle`].
25//! - [`aimux_stream_text`] callbacks receive `*const c_char` pointers that are
26//!   valid **only for the duration of the callback**. The callback must copy
27//!   the data synchronously; the backing buffer is freed when the callback
28//!   returns.
29//!
30//! ## Concurrency
31//!
32//! All async provider work runs on a shared multi-threaded tokio runtime. The
33//! C ABI functions are synchronous: they `block_on` the runtime until the
34//! operation completes. Callbacks execute on the same thread/call-stack that
35//! invoked the FFI function, so they must **not** re-enter the FFI layer:
36//! a nested `block_on` on the same thread makes tokio **panic** ("Cannot start
37//! a runtime from within a runtime"). Rust's non-unwind `extern "C"` ABI does
38//! not allow a panic to propagate — the process terminates (and under this
39//! workspace's release profile, `panic = "abort"`, it terminates at the panic
40//! site). Either way the re-entrant call must be rejected before it reaches
41//! the runtime; the thread-local guard in `ffi_block_on` does that
42//! (issue M7).
43#![allow(clippy::not_unsafe_ptr_arg_deref)]
44// `extern "C"` entry points dereference raw pointers (`*const c_char`) by
45// design: the C ABI contract requires callers to pass valid pointers (see
46// memory-ownership docs above), so the functions are safe only on the C side.
47
48use std::collections::HashMap;
49use std::ffi::{CStr, CString};
50use std::os::raw::{c_char, c_void};
51use std::sync::atomic::{AtomicU64, Ordering};
52use std::sync::{Arc, Mutex, OnceLock};
53
54use serde::de::DeserializeOwned;
55
56use aimux_core::AbortSignal;
57use aimux_core::AiMuxError;
58use aimux_core::generate::{
59    GenerateTextOptions, GenerateTextResult, generate_object, generate_text,
60    generate_text_as_openai, generate_text_result_to_chat_completion, stream_text,
61    stream_text_as_openai,
62};
63use aimux_core::language_model::LanguageModel;
64use aimux_core::message::ModelPrompt;
65use aimux_core::openai_output::OpenAiStreamOptions;
66use aimux_core::parse_tool_call::{
67    ToolCallRepairReply, apply_tool_call_repair, apply_tool_call_repair_to_result,
68    tool_call_repair_context, tool_call_repair_inputs,
69};
70use aimux_core::provider::Provider;
71use aimux_core::recording::RecordingError;
72use aimux_core::tool::ToolCall;
73use aimux_core::trace::{RingTraceStore, TraceFilter, TraceLayer};
74use aimux_providers::anthropic::{AnthropicConfig, AnthropicProvider};
75use aimux_providers::anthropic_aws::{AnthropicAwsProvider, AnthropicAwsProviderConfig};
76use aimux_providers::azure::{AzureConfig, AzureProvider};
77use aimux_providers::bedrock::{BedrockProvider, BedrockProviderConfig};
78use aimux_providers::cohere::{CohereConfig, CohereProvider};
79use aimux_providers::google::{GoogleConfig, GoogleProvider};
80use aimux_providers::mistral::{MistralConfig, MistralProvider};
81use aimux_providers::openai::{OpenAIConfig, OpenAIProvider};
82use aimux_providers::tavily::{TavilyConfig, TavilyProvider};
83use aimux_providers::vertex::{VertexProvider, VertexProviderConfig};
84use aimux_providers::xai::{XAIConfig, XAIProvider};
85use aimux_providers::{ProviderOptions, provider, provider_handle};
86
87use futures::StreamExt;
88use tokio::runtime::Runtime;
89
90// ─────────────────────────────────────────────────────────────────────────────
91// Global state: handle registry + tokio runtime
92// ─────────────────────────────────────────────────────────────────────────────
93
94/// A type-erased FFI handle. One registry holds models, providers, sessions
95/// and abort signals.
96#[derive(Clone)]
97enum HandleEntry {
98    Language(Arc<dyn LanguageModel>),
99    Provider(Arc<dyn aimux_core::provider::Provider>),
100    Embedding(Arc<dyn aimux_core::embedding_model::EmbeddingModel>),
101    Speech(Arc<dyn aimux_core::speech_model::SpeechModel>),
102    Image(Arc<dyn aimux_core::image_model::ImageModel>),
103    Transcription(Arc<dyn aimux_core::transcription_model::TranscriptionModel>),
104    Reranking(Arc<dyn aimux_core::reranking_model::RerankingModel>),
105    Video(Arc<dyn aimux_core::video_model::VideoModel>),
106    Search(Arc<dyn aimux_core::search_model::SearchModel>),
107    Files(Arc<dyn aimux_core::files_model::Files>),
108    /// Live transcription streaming session (RFC-0028 Phase 2).
109    TranscriptionSession(Arc<transcription_session::TranscriptionFfiSession>),
110    Abort(AbortSignal),
111}
112
113type Registry = HashMap<u64, HandleEntry>;
114
115static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
116static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
117
118fn registry() -> &'static Mutex<Registry> {
119    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
120}
121
122/// Register a model instance, returning its opaque `u64` handle.
123///
124/// Handles start at 1; 0 is reserved for "failure / invalid".
125fn intern_model(model: Arc<dyn LanguageModel>) -> u64 {
126    intern_handle(HandleEntry::Language(model))
127}
128
129fn intern_handle(h: HandleEntry) -> u64 {
130    let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
131    registry()
132        .lock()
133        .expect("aimux-ffi: registry mutex poisoned")
134        .insert(handle, h);
135    handle
136}
137
138/// Look up any handle.
139fn get_handle(handle: u64) -> Option<HandleEntry> {
140    registry()
141        .lock()
142        .expect("aimux-ffi: registry mutex poisoned")
143        .get(&handle)
144        .cloned()
145}
146
147/// Look up a model by handle, cloning the `Arc` out of the registry.
148fn get_model(handle: u64) -> Option<Arc<dyn LanguageModel>> {
149    match get_handle(handle)? {
150        HandleEntry::Language(m) => Some(m),
151        _ => None,
152    }
153}
154
155/// A model handle, or [`FfiError::InvalidHandle`] (`"model"`).
156fn model_of(handle: u64) -> Result<Arc<dyn LanguageModel>, FfiError> {
157    get_model(handle).ok_or(FfiError::InvalidHandle { expected: "model" })
158}
159
160/// A live handle of any registered type, or [`FfiError::InvalidHandle`] naming what was
161/// expected. Callers match the arm they need and fall back to the same error.
162fn entry_of(handle: u64, expected: &'static str) -> Result<HandleEntry, FfiError> {
163    get_handle(handle).ok_or(FfiError::InvalidHandle { expected })
164}
165
166fn get_abort_signal(handle: u64) -> Option<AbortSignal> {
167    match get_handle(handle)? {
168        HandleEntry::Abort(signal) => Some(signal),
169        _ => None,
170    }
171}
172
173fn abort_of(handle: u64) -> Result<AbortSignal, FfiError> {
174    get_abort_signal(handle).ok_or(FfiError::InvalidHandle { expected: "abort" })
175}
176
177/// Remove a handle from the registry (the model drops when the last ref goes).
178/// Trace stores bound to the handle are released with it.
179fn drop_handle(handle: u64) {
180    let removed = registry()
181        .lock()
182        .expect("aimux-ffi: registry mutex poisoned")
183        .remove(&handle);
184    // A transcription session owns a driver task: abort and join it here too,
185    // so the generic drop is never a silent leak (the registry mutex is
186    // already released — the join must not hold it).
187    if let Some(HandleEntry::TranscriptionSession(s)) = removed {
188        s.terminate();
189    }
190    if let Some(stores) = TRACE_STORES.get() {
191        stores
192            .lock()
193            .expect("aimux-ffi: trace registry mutex poisoned")
194            .remove(&handle);
195    }
196}
197
198fn drop_abort_signal(handle: u64) {
199    let mut registry = registry()
200        .lock()
201        .expect("aimux-ffi: registry mutex poisoned");
202    if matches!(registry.get(&handle), Some(HandleEntry::Abort(_))) {
203        registry.remove(&handle);
204    }
205}
206
207/// Transcription streaming sessions (RFC-0028 Phase 2).
208mod transcription_session;
209
210/// The shared tokio runtime driving all async provider calls.
211fn runtime() -> &'static Runtime {
212    static RUNTIME: OnceLock<Runtime> = OnceLock::new();
213    RUNTIME.get_or_init(|| {
214        tokio::runtime::Runtime::new().expect("aimux-ffi: failed to build tokio runtime")
215    })
216}
217
218thread_local! {
219    /// Re-entrancy guard: set while an FFI entry point is `block_on`-ing the
220    /// shared runtime on the current thread.
221    ///
222    /// Stream callbacks (`on_part`/`on_done`) run synchronously on
223    /// the same thread/call-stack that entered the FFI function, so a callback
224    /// that calls back into the FFI layer would enter a second `block_on` on
225    /// this thread. tokio rejects nested `block_on` with a **panic**; Rust's
226    /// non-unwind `extern "C"` ABI terminates the process rather than letting
227    /// the panic propagate (and `panic = "abort"` terminates at the panic site).
228    /// `ffi_block_on` checks this guard and turns that re-entrant call into
229    /// a [`FfiError::ReentrantCall`] instead (issue M7).
230    static IN_FFI_BLOCK_ON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
231}
232
233/// Run a future on the shared runtime from an FFI entry point (issue M7).
234///
235/// Rejects re-entrant calls made from inside a stream callback, returning
236/// [`FfiError::ReentrantCall`] instead of letting tokio's nested `block_on` panic —
237/// a non-unwind `extern "C"` call never lets the panic propagate, so the
238/// process would terminate. The guard is released when the future completes,
239/// including when it panics.
240fn ffi_block_on<F, T>(f: F) -> Result<T, FfiError>
241where
242    F: std::future::Future<Output = T>,
243{
244    IN_FFI_BLOCK_ON.with(|flag| {
245        if flag.replace(true) {
246            return Err(FfiError::ReentrantCall);
247        }
248        struct Reset;
249        impl Drop for Reset {
250            fn drop(&mut self) {
251                IN_FFI_BLOCK_ON.with(|flag| flag.set(false));
252            }
253        }
254        let _reset = Reset;
255        Ok(runtime().block_on(f))
256    })
257}
258
259// ─────────────────────────────────────────────────────────────────────────────
260// Error model (aimux-error.h)
261// ─────────────────────────────────────────────────────────────────────────────
262
263/// A failure detected while lifting arguments, using handles, invoking a
264/// callback, or lowering a result across the C ABI.
265#[derive(Debug, Clone)]
266enum FfiError {
267    /// A required pointer argument was NULL.
268    NullPointer { argument: &'static str },
269    /// A string argument was not valid UTF-8.
270    InvalidUtf8 { argument: &'static str },
271    /// A JSON-text argument of this wire format (`prompt_json`, `opts_json`,
272    /// `config_json`, …) did not parse. Not `AiMuxError::JsonParse` — that
273    /// one is about provider responses.
274    InvalidWireJson {
275        argument: &'static str,
276        message: String,
277    },
278    /// A handle argument is 0, released, or has the wrong handle type.
279    InvalidHandle { expected: &'static str },
280    /// The FFI was re-entered from inside one of its own callbacks.
281    ReentrantCall,
282    /// A result could not be serialized to the wire format.
283    ResultSerialization { message: String },
284    /// A host callback panicked (caught inside the C ABI).
285    CallbackFailure { message: String },
286}
287
288impl std::fmt::Display for FfiError {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            FfiError::NullPointer { argument } => write!(f, "{argument}: must not be NULL"),
292            FfiError::InvalidUtf8 { argument } => write!(f, "{argument}: must be valid UTF-8"),
293            FfiError::InvalidWireJson { argument, message } => {
294                write!(f, "{argument}: invalid JSON: {message}")
295            }
296            FfiError::InvalidHandle { expected } => {
297                write!(f, "invalid or expired {expected} handle")
298            }
299            FfiError::ReentrantCall => {
300                f.write_str("re-entrant FFI call from within a callback is not allowed")
301            }
302            FfiError::ResultSerialization { message } => {
303                write!(f, "could not serialize result: {message}")
304            }
305            FfiError::CallbackFailure { message } => write!(f, "host callback failed: {message}"),
306        }
307    }
308}
309
310/// The errors that can cross the Aimux C ABI.
311enum AiMuxFfiError {
312    AiMux(AiMuxError),
313    Recording(RecordingError),
314    Ffi(FfiError),
315}
316
317impl From<AiMuxError> for AiMuxFfiError {
318    fn from(inner: AiMuxError) -> Self {
319        AiMuxFfiError::AiMux(inner)
320    }
321}
322impl From<RecordingError> for AiMuxFfiError {
323    fn from(inner: RecordingError) -> Self {
324        AiMuxFfiError::Recording(inner)
325    }
326}
327impl From<FfiError> for AiMuxFfiError {
328    fn from(e: FfiError) -> Self {
329        AiMuxFfiError::Ffi(e)
330    }
331}
332
333impl std::fmt::Display for AiMuxFfiError {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        match self {
336            AiMuxFfiError::AiMux(e) => e.fmt(f),
337            AiMuxFfiError::Recording(e) => e.fmt(f),
338            AiMuxFfiError::Ffi(e) => e.fmt(f),
339        }
340    }
341}
342
343/// The owned error returned by a failed C ABI invocation. Opaque to C: read it
344/// with the `aimux_error_*` getters, then release it once with
345/// `aimux_error_free`.
346#[allow(non_camel_case_types)]
347pub struct aimux_error_t {
348    error: AiMuxFfiError,
349}
350
351type FfiResult<T> = Result<T, AiMuxFfiError>;
352
353/// Ok → run `write` with the value and return NULL; Err → hand the owned
354/// error to the caller.
355fn finish<T>(r: FfiResult<T>, write: impl FnOnce(T)) -> *mut aimux_error_t {
356    match r {
357        Ok(v) => {
358            write(v);
359            std::ptr::null_mut()
360        }
361        Err(error) => Box::into_raw(Box::new(aimux_error_t { error })),
362    }
363}
364
365/// Entry-point shape: `uint64_t *out_handle`. NULL out-param is a
366/// `NullPointer("out_handle")`; the sentinel 0 is written before `f` runs.
367fn with_out_handle(out_handle: *mut u64, f: impl FnOnce() -> FfiResult<u64>) -> *mut aimux_error_t {
368    if out_handle.is_null() {
369        return finish(
370            Err(FfiError::NullPointer {
371                argument: "out_handle",
372            }
373            .into()),
374            |_: u64| {},
375        );
376    }
377    unsafe { *out_handle = 0 };
378    finish(f(), |h| unsafe { *out_handle = h })
379}
380
381/// Entry-point shape: `char **out_json` (name given by `argument`). NULL
382/// out-param is a `NullPointer`; the sentinel NULL is written before `f` runs.
383fn with_out_string(
384    out: *mut *mut c_char,
385    argument: &'static str,
386    f: impl FnOnce() -> FfiResult<String>,
387) -> *mut aimux_error_t {
388    if out.is_null() {
389        return finish(
390            Err(FfiError::NullPointer { argument }.into()),
391            |_: String| {},
392        );
393    }
394    unsafe { *out = std::ptr::null_mut() };
395    finish(f(), |s| unsafe { *out = into_cstring_raw(s) })
396}
397
398/// Entry-point shape: no result.
399fn no_result(f: impl FnOnce() -> FfiResult<()>) -> *mut aimux_error_t {
400    finish(f(), |()| {})
401}
402
403// ── aimux_error_* ────────────────────────────────────────────────────────────
404
405/// Release a returned error. NULL-safe; call exactly once.
406#[unsafe(no_mangle)]
407pub extern "C" fn aimux_error_free(err: *mut aimux_error_t) {
408    if !err.is_null() {
409        // SAFETY: `err` came from `Box::into_raw` in `finish` and the caller
410        // passes it exactly once.
411        drop(unsafe { Box::from_raw(err) });
412    }
413}
414
415// ── Unified error codes (aimux-error.h `aimux_error_code_t`) ─────────────────
416
417pub const AIMUX_OK: i32 = 0;
418// 1 is the catch-all `Other` (it was `AIMUX_E_UNKNOWN`, whose slot it inherits:
419// a code a binding does not know is a header mismatch, not a user-facing code).
420pub const AIMUX_E_OTHER: i32 = 1;
421pub const AIMUX_E_JSON_PARSE: i32 = 2;
422pub const AIMUX_E_INVALID_RESPONSE_DATA: i32 = 3;
423// 4 is retired: it was the legacy catch-all `Tool` variant, which nothing
424// ever produced; the typed tool-contract codes are 15..17. 14 is claimed by
425// the in-flight request-pipeline change (`Retry`).
426pub const AIMUX_E_INVALID_ARGUMENT: i32 = 5;
427pub const AIMUX_E_INVALID_PROMPT: i32 = 6;
428pub const AIMUX_E_TOKEN_EXPIRED: i32 = 7;
429pub const AIMUX_E_UNSUPPORTED_FUNCTIONALITY: i32 = 8;
430pub const AIMUX_E_NO_SUCH_MODEL: i32 = 9;
431pub const AIMUX_E_NO_SUCH_PROVIDER: i32 = 10;
432pub const AIMUX_E_API_CALL: i32 = 11;
433pub const AIMUX_E_TIMEOUT: i32 = 12;
434pub const AIMUX_E_ABORTED: i32 = 13;
435// `Retry` (newest variant) reclaims the slot the pre-unification `Other`
436// vacated — the opaque-pointer ABI break means no old caller can misread it.
437pub const AIMUX_E_RETRY: i32 = 14;
438pub const AIMUX_E_NO_SUCH_TOOL: i32 = 15;
439pub const AIMUX_E_INVALID_TOOL_INPUT: i32 = 16;
440pub const AIMUX_E_TOOL_CALL_REPAIR: i32 = 17;
441
442// 100..105 preserve `RecordingError` as a separate high-level type while C
443// uses one code space for every returned error.
444pub const AIMUX_E_RECORDING_INIT: i32 = 100;
445pub const AIMUX_E_RECORDING_OPEN_FILE: i32 = 101;
446pub const AIMUX_E_RECORDING_SPAWN: i32 = 102;
447pub const AIMUX_E_RECORDING_WRITER_GONE: i32 = 103;
448pub const AIMUX_E_RECORDING_FLUSH_TIMEOUT: i32 = 104;
449pub const AIMUX_E_RECORDING_WRITE: i32 = 105;
450
451// 200..206 are failures detected while lifting arguments, looking up handles,
452// invoking callbacks, or lowering a result across the C ABI.
453pub const AIMUX_E_FFI_NULL_POINTER: i32 = 200;
454pub const AIMUX_E_FFI_INVALID_UTF8: i32 = 201;
455pub const AIMUX_E_FFI_INVALID_WIRE_JSON: i32 = 202;
456pub const AIMUX_E_FFI_INVALID_HANDLE: i32 = 203;
457pub const AIMUX_E_FFI_REENTRANT_CALL: i32 = 204;
458pub const AIMUX_E_FFI_RESULT_SERIALIZATION: i32 = 205;
459pub const AIMUX_E_FFI_CALLBACK_FAILURE: i32 = 206;
460
461// ── `aimux_transcription_next_part_state_t` ──────────────────────────────────
462pub const AIMUX_TRANSCRIPTION_NEXT_PART_PART: i32 = 1;
463pub const AIMUX_TRANSCRIPTION_NEXT_PART_ENDED: i32 = 2;
464pub const AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT: i32 = 3;
465
466fn aimux_error_code_of(err: &AiMuxError) -> i32 {
467    match err {
468        AiMuxError::ApiCall { .. } => AIMUX_E_API_CALL,
469        AiMuxError::Retry(_) => AIMUX_E_RETRY,
470        AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE,
471        AiMuxError::InvalidResponseData(_) => AIMUX_E_INVALID_RESPONSE_DATA,
472        AiMuxError::NoSuchTool { .. } => AIMUX_E_NO_SUCH_TOOL,
473        AiMuxError::InvalidToolInput { .. } => AIMUX_E_INVALID_TOOL_INPUT,
474        AiMuxError::ToolCallRepair { .. } => AIMUX_E_TOOL_CALL_REPAIR,
475        AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT,
476        AiMuxError::InvalidPrompt(_) => AIMUX_E_INVALID_PROMPT,
477        AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED,
478        AiMuxError::UnsupportedFunctionality(_) => AIMUX_E_UNSUPPORTED_FUNCTIONALITY,
479        AiMuxError::NoSuchModel { .. } => AIMUX_E_NO_SUCH_MODEL,
480        AiMuxError::NoSuchProvider { .. } => AIMUX_E_NO_SUCH_PROVIDER,
481        AiMuxError::Timeout(_) => AIMUX_E_TIMEOUT,
482        AiMuxError::Aborted(_) => AIMUX_E_ABORTED,
483        AiMuxError::Other(_) => AIMUX_E_OTHER,
484    }
485}
486
487fn recording_error_code_of(e: &RecordingError) -> i32 {
488    use RecordingError as R;
489    match e {
490        R::Init { .. } => AIMUX_E_RECORDING_INIT,
491        R::OpenFile { .. } => AIMUX_E_RECORDING_OPEN_FILE,
492        R::Spawn { .. } => AIMUX_E_RECORDING_SPAWN,
493        R::WriterGone => AIMUX_E_RECORDING_WRITER_GONE,
494        R::FlushTimeout => AIMUX_E_RECORDING_FLUSH_TIMEOUT,
495        R::Write(_) => AIMUX_E_RECORDING_WRITE,
496    }
497}
498
499fn ffi_error_code_of(e: &FfiError) -> i32 {
500    match e {
501        FfiError::NullPointer { .. } => AIMUX_E_FFI_NULL_POINTER,
502        FfiError::InvalidUtf8 { .. } => AIMUX_E_FFI_INVALID_UTF8,
503        FfiError::InvalidWireJson { .. } => AIMUX_E_FFI_INVALID_WIRE_JSON,
504        FfiError::InvalidHandle { .. } => AIMUX_E_FFI_INVALID_HANDLE,
505        FfiError::ReentrantCall => AIMUX_E_FFI_REENTRANT_CALL,
506        FfiError::ResultSerialization { .. } => AIMUX_E_FFI_RESULT_SERIALIZATION,
507        FfiError::CallbackFailure { .. } => AIMUX_E_FFI_CALLBACK_FAILURE,
508    }
509}
510
511fn error_code_of(e: &AiMuxFfiError) -> i32 {
512    match e {
513        AiMuxFfiError::AiMux(e) => aimux_error_code_of(e),
514        AiMuxFfiError::Recording(e) => recording_error_code_of(e),
515        AiMuxFfiError::Ffi(e) => ffi_error_code_of(e),
516    }
517}
518
519fn api_call(e: &AiMuxError) -> Option<&aimux_core::ApiCallError> {
520    match e {
521        AiMuxError::ApiCall(d) => Some(d),
522        _ => None,
523    }
524}
525
526fn retry(e: &AiMuxError) -> Option<&aimux_core::RetryError> {
527    match e {
528        AiMuxError::Retry(r) => Some(r),
529        _ => None,
530    }
531}
532
533/// Apply `f` to the `AiMuxError` stored in a returned error.
534///
535/// The closure keeps the borrowed error scoped to this call instead of
536/// manufacturing an unconstrained lifetime from the raw pointer.
537fn map_aimux_error<T>(err: *const aimux_error_t, f: impl FnOnce(&AiMuxError) -> T) -> Option<T> {
538    // SAFETY: the C API requires NULL or a live error returned by `finish`.
539    match unsafe { err.as_ref() } {
540        Some(aimux_error_t {
541            error: AiMuxFfiError::AiMux(e),
542        }) => Some(f(e)),
543        _ => None,
544    }
545}
546
547fn opt_cstring(v: Option<String>) -> *mut c_char {
548    v.map_or(std::ptr::null_mut(), into_cstring_raw)
549}
550
551// ── aimux_error_* getters ────────────────────────────────────────────
552//
553// One getter per fact. `code` and `message` answer for every returned error;
554// the rest belong to one AiMuxError code and return NULL / -1 / 0 under every
555// other code or for NULL. Strings are owned by the caller (aimux_free_string).
556
557/// Machine-readable code (`aimux_error_code_t`); `AIMUX_OK` for NULL.
558#[unsafe(no_mangle)]
559pub extern "C" fn aimux_error_code(err: *const aimux_error_t) -> i32 {
560    // SAFETY: NULL or a live error returned by `finish`.
561    unsafe { err.as_ref() }.map_or(AIMUX_OK, |e| error_code_of(&e.error))
562}
563
564/// Human-readable description for every code; NULL only for NULL.
565#[unsafe(no_mangle)]
566pub extern "C" fn aimux_error_message(err: *const aimux_error_t) -> *mut c_char {
567    // SAFETY: NULL or a live error returned by `finish`.
568    unsafe { err.as_ref() }.map_or(std::ptr::null_mut(), |e| {
569        into_cstring_raw(e.error.to_string())
570    })
571}
572
573/// HTTP status: the observed status under `AIMUX_E_API_CALL`, 401 by
574/// definition under `AIMUX_E_TOKEN_EXPIRED`, -1 otherwise or when no response
575/// was observed (`AiMuxError::status_code`).
576#[unsafe(no_mangle)]
577pub extern "C" fn aimux_error_status(err: *const aimux_error_t) -> i32 {
578    map_aimux_error(err, AiMuxError::status_code)
579        .flatten()
580        .map_or(-1, i32::from)
581}
582
583/// `AIMUX_E_API_CALL`: retry hint in ms (0 = retry now), or -1 when none.
584#[unsafe(no_mangle)]
585pub extern "C" fn aimux_error_retry_ms(err: *const aimux_error_t) -> i64 {
586    map_aimux_error(err, AiMuxError::retry_after_hint)
587        .flatten()
588        .unwrap_or(-1)
589}
590
591/// 1 when retrying may help, 0 when it will not — the core's verdict. Do not
592/// infer it from `status`: a statusless `AIMUX_E_API_CALL` may be either.
593#[unsafe(no_mangle)]
594pub extern "C" fn aimux_error_retryable(err: *const aimux_error_t) -> i32 {
595    map_aimux_error(err, AiMuxError::is_retryable).unwrap_or(false) as i32
596}
597
598/// `AIMUX_E_API_CALL`: the provider's own error code, e.g. "insufficient_quota".
599#[unsafe(no_mangle)]
600pub extern "C" fn aimux_error_provider_code(err: *const aimux_error_t) -> *mut c_char {
601    opt_cstring(map_aimux_error(err, |e| api_call(e)?.provider_code.clone()).flatten())
602}
603
604/// `AIMUX_E_API_CALL`: the failure's own text ("slow down"), without the
605/// composed prefix `message` carries ("API call error: HTTP 429: slow down").
606#[unsafe(no_mangle)]
607pub extern "C" fn aimux_error_provider_message(err: *const aimux_error_t) -> *mut c_char {
608    opt_cstring(
609        map_aimux_error(err, |e| Some(api_call(e)?.message.clone()))
610            .flatten()
611            .filter(|m| !m.is_empty()),
612    )
613}
614
615/// `AIMUX_E_API_CALL`: raw response body.
616#[unsafe(no_mangle)]
617pub extern "C" fn aimux_error_response_body(err: *const aimux_error_t) -> *mut c_char {
618    opt_cstring(map_aimux_error(err, |e| api_call(e)?.response_body.clone()).flatten())
619}
620
621/// `AIMUX_E_API_CALL`: sanitized request URL.
622#[unsafe(no_mangle)]
623pub extern "C" fn aimux_error_url(err: *const aimux_error_t) -> *mut c_char {
624    opt_cstring(
625        map_aimux_error(err, |e| Some(api_call(e)?.url.clone()))
626            .flatten()
627            .filter(|u| !u.is_empty()),
628    )
629}
630
631/// `AIMUX_E_API_CALL`: sanitized request body values as a JSON string; NULL
632/// when the request carried none (JSON `null`).
633#[unsafe(no_mangle)]
634pub extern "C" fn aimux_error_request_body_values(err: *const aimux_error_t) -> *mut c_char {
635    opt_cstring(
636        map_aimux_error(err, |e| {
637            let values = &api_call(e)?.request_body_values;
638            if values.is_null() {
639                return None;
640            }
641            serde_json::to_string(values).ok()
642        })
643        .flatten(),
644    )
645}
646
647/// `AIMUX_E_API_CALL`: sanitized response headers as one JSON object string
648/// of name → value pairs, e.g. `{"retry-after-ms":"1500"}`.
649#[unsafe(no_mangle)]
650pub extern "C" fn aimux_error_response_headers(err: *const aimux_error_t) -> *mut c_char {
651    opt_cstring(
652        map_aimux_error(err, |e| {
653            serde_json::to_string(api_call(e)?.response_headers.as_ref()?).ok()
654        })
655        .flatten(),
656    )
657}
658
659/// `AIMUX_E_API_CALL`: parsed provider error data as a JSON string.
660#[unsafe(no_mangle)]
661pub extern "C" fn aimux_error_provider_data(err: *const aimux_error_t) -> *mut c_char {
662    opt_cstring(
663        map_aimux_error(err, |e| {
664            serde_json::to_string(api_call(e)?.data.as_ref()?).ok()
665        })
666        .flatten(),
667    )
668}
669
670/// `AIMUX_E_RETRY`: why retrying stopped — the wire name of
671/// `RetryErrorReason`: "maxRetriesExceeded" or "errorNotRetryable".
672#[unsafe(no_mangle)]
673pub extern "C" fn aimux_error_retry_reason(err: *const aimux_error_t) -> *mut c_char {
674    let reason = map_aimux_error(err, |e| {
675        Some(match retry(e)?.reason {
676            aimux_core::RetryErrorReason::MaxRetriesExceeded => "maxRetriesExceeded",
677            aimux_core::RetryErrorReason::ErrorNotRetryable => "errorNotRetryable",
678        })
679    })
680    .flatten();
681    opt_cstring(reason.map(str::to_string))
682}
683
684/// `AIMUX_E_RETRY`: number of recorded attempt errors; 0 under any other
685/// code or for NULL.
686#[unsafe(no_mangle)]
687pub extern "C" fn aimux_error_retry_count(err: *const aimux_error_t) -> i32 {
688    map_aimux_error(err, |e| Some(retry(e)?.errors.len() as i32))
689        .flatten()
690        .unwrap_or(0)
691}
692
693/// `AIMUX_E_RETRY`: the attempt error at `index` (0-based, oldest first; the
694/// last entry is the final attempt) as a NEW owned error the caller reads
695/// with these same getters and releases with `aimux_error_free`,
696/// independently of the parent. NULL when `index` is out of range or under
697/// any other code.
698#[unsafe(no_mangle)]
699pub extern "C" fn aimux_error_retry_error_at(
700    err: *const aimux_error_t,
701    index: i32,
702) -> *mut aimux_error_t {
703    if index < 0 {
704        return std::ptr::null_mut();
705    }
706    map_aimux_error(err, |e| {
707        retry(e)?.errors.get(index as usize).map(|attempt| {
708            Box::into_raw(Box::new(aimux_error_t {
709                error: AiMuxFfiError::AiMux(attempt.clone()),
710            }))
711        })
712    })
713    .flatten()
714    .unwrap_or_else(std::ptr::null_mut)
715}
716
717/// `AIMUX_E_NO_SUCH_MODEL`: the model id that was asked for.
718#[unsafe(no_mangle)]
719pub extern "C" fn aimux_error_model_id(err: *const aimux_error_t) -> *mut c_char {
720    opt_cstring(
721        map_aimux_error(err, |e| match e {
722            AiMuxError::NoSuchModel { model_id, .. } => Some(model_id.clone()),
723            _ => None,
724        })
725        .flatten(),
726    )
727}
728
729/// `AIMUX_E_NO_SUCH_MODEL`: the model type it was asked for as.
730#[unsafe(no_mangle)]
731pub extern "C" fn aimux_error_model_type(err: *const aimux_error_t) -> *mut c_char {
732    opt_cstring(
733        map_aimux_error(err, |e| match e {
734            AiMuxError::NoSuchModel { model_type, .. } => Some(model_type.clone()),
735            _ => None,
736        })
737        .flatten(),
738    )
739}
740
741/// `AIMUX_E_NO_SUCH_PROVIDER`: the provider id that was asked for.
742#[unsafe(no_mangle)]
743pub extern "C" fn aimux_error_provider_id(err: *const aimux_error_t) -> *mut c_char {
744    opt_cstring(
745        map_aimux_error(err, |e| match e {
746            AiMuxError::NoSuchProvider { provider_id } => Some(provider_id.clone()),
747            _ => None,
748        })
749        .flatten(),
750    )
751}
752
753/// `AIMUX_E_NO_SUCH_TOOL` / `AIMUX_E_INVALID_TOOL_INPUT`: the tool name the
754/// model called.
755#[unsafe(no_mangle)]
756pub extern "C" fn aimux_error_tool_name(err: *const aimux_error_t) -> *mut c_char {
757    opt_cstring(
758        map_aimux_error(err, |e| match e {
759            AiMuxError::NoSuchTool { tool_name, .. }
760            | AiMuxError::InvalidToolInput { tool_name, .. } => Some(tool_name.clone()),
761            _ => None,
762        })
763        .flatten(),
764    )
765}
766
767/// `AIMUX_E_NO_SUCH_TOOL`: the available tool names as a JSON string array,
768/// or NULL when no tool set was supplied.
769#[unsafe(no_mangle)]
770pub extern "C" fn aimux_error_available_tools(err: *const aimux_error_t) -> *mut c_char {
771    opt_cstring(
772        map_aimux_error(err, |e| match e {
773            AiMuxError::NoSuchTool {
774                available_tools: Some(tools),
775                ..
776            } => serde_json::to_string(tools).ok(),
777            _ => None,
778        })
779        .flatten(),
780    )
781}
782
783/// `AIMUX_E_INVALID_TOOL_INPUT` / `AIMUX_E_NO_SUCH_TOOL`: the raw argument
784/// text the model produced, or NULL when unavailable.
785#[unsafe(no_mangle)]
786pub extern "C" fn aimux_error_tool_input(err: *const aimux_error_t) -> *mut c_char {
787    opt_cstring(
788        map_aimux_error(err, |e| match e {
789            AiMuxError::InvalidToolInput { tool_input, .. } => Some(tool_input.clone()),
790            AiMuxError::NoSuchTool { tool_input, .. } => tool_input.clone(),
791            _ => None,
792        })
793        .flatten(),
794    )
795}
796
797/// `AIMUX_E_TOOL_CALL_REPAIR`: the original lookup/parse/validation error as
798/// externally-tagged wire JSON — the same encoding as `ToolCall.error`.
799#[unsafe(no_mangle)]
800pub extern "C" fn aimux_error_original_error(err: *const aimux_error_t) -> *mut c_char {
801    opt_cstring(
802        map_aimux_error(err, |e| match e {
803            AiMuxError::ToolCallRepair { original_error, .. } => {
804                serde_json::to_string(original_error).ok()
805            }
806            _ => None,
807        })
808        .flatten(),
809    )
810}
811
812// ─────────────────────────────────────────────────────────────────────────────
813// Argument helpers
814// ─────────────────────────────────────────────────────────────────────────────
815
816/// Read a required string argument, naming it in the failure
817/// (`NullPointer` / `InvalidUtf8`).
818///
819/// # Safety: `ptr` is null or a valid NUL-terminated C string.
820fn str_arg(ptr: *const c_char, argument: &'static str) -> Result<String, FfiError> {
821    if ptr.is_null() {
822        return Err(FfiError::NullPointer { argument });
823    }
824    // SAFETY: caller guarantees `ptr` is a valid NUL-terminated C string.
825    unsafe { CStr::from_ptr(ptr) }
826        .to_str()
827        .map(str::to_owned)
828        .map_err(|_| FfiError::InvalidUtf8 { argument })
829}
830
831/// Read an optional string argument: NULL means "absent"; a non-NULL pointer
832/// must be valid UTF-8 or it is [`FfiError::InvalidUtf8`] — absent and
833/// malformed are different things.
834fn opt_str_arg(ptr: *const c_char, argument: &'static str) -> Result<Option<String>, FfiError> {
835    if ptr.is_null() {
836        return Ok(None);
837    }
838    str_arg(ptr, argument).map(Some)
839}
840
841/// Build (a, b) from two required C strings.
842fn parse_two_args(
843    a: *const c_char,
844    an: &'static str,
845    b: *const c_char,
846    bn: &'static str,
847) -> Result<(String, String), FfiError> {
848    Ok((str_arg(a, an)?, str_arg(b, bn)?))
849}
850
851/// Parse four required C string arguments; any null fails the whole call.
852#[allow(clippy::too_many_arguments)]
853fn parse_four_args(
854    a: *const c_char,
855    an: &'static str,
856    b: *const c_char,
857    bn: &'static str,
858    c: *const c_char,
859    cn: &'static str,
860    d: *const c_char,
861    dn: &'static str,
862) -> Result<(String, String, String, String), FfiError> {
863    Ok((
864        str_arg(a, an)?,
865        str_arg(b, bn)?,
866        str_arg(c, cn)?,
867        str_arg(d, dn)?,
868    ))
869}
870
871/// Parse the base_url argument; NULL or an empty string means unset.
872fn parse_base_url(base_url: *const c_char) -> Result<Option<String>, FfiError> {
873    Ok(opt_str_arg(base_url, "base_url")?.filter(|url| !url.is_empty()))
874}
875
876/// Where a serde failure on a wire-JSON argument belongs. Malformed text
877/// (syntax / EOF) is this layer's finding: [`FfiError::InvalidWireJson`].
878/// Well-formed JSON of the wrong shape (missing field, wrong type) is what the
879/// core would reject: [`AiMuxError::InvalidArgument`]. `detail` is the
880/// message to carry (usually `e.to_string()`, sometimes with a line prefix).
881fn wire_failure(argument: &'static str, e: &serde_json::Error, detail: String) -> AiMuxFfiError {
882    match e.classify() {
883        serde_json::error::Category::Data => {
884            AiMuxError::InvalidArgument(format!("{argument}: {detail}")).into()
885        }
886        _ => FfiError::InvalidWireJson {
887            argument,
888            message: detail,
889        }
890        .into(),
891    }
892}
893
894/// [`wire_failure`] with the serde message as the detail.
895fn wire_err(argument: &'static str, e: serde_json::Error) -> AiMuxFfiError {
896    let detail = e.to_string();
897    wire_failure(argument, &e, detail)
898}
899
900/// Parse a JSON C-string argument into `T`. NULL / non-UTF-8 → FFI
901/// `NullPointer` / `InvalidUtf8`; text that does not parse → FFI
902/// `InvalidWireJson`; text that parses but violates `T`'s schema →
903/// `AiMuxError::InvalidArgument` (see [`wire_failure`]).
904fn parse_json_arg<T: DeserializeOwned>(json: *const c_char, name: &'static str) -> FfiResult<T> {
905    let s = str_arg(json, name)?;
906    serde_json::from_str::<T>(&s).map_err(|e| wire_err(name, e))
907}
908
909/// Parse the prompt JSON accepted by the FFI (`prompt_json`, required).
910///
911/// Accepts either a bare prompt value (`"text"` or `[{...}]`) or a wrapper
912/// object `{"prompt": <value>}`.
913fn parse_prompt_arg(prompt_json: *const c_char) -> FfiResult<ModelPrompt> {
914    let s = str_arg(prompt_json, "prompt_json")?;
915    let parse = |json: &str| -> Result<ModelPrompt, serde_json::Error> {
916        let value: serde_json::Value = serde_json::from_str(json)?;
917        let inner = match &value {
918            serde_json::Value::Object(obj) if obj.len() == 1 && obj.contains_key("prompt") => {
919                obj.get("prompt").expect("checked by guard")
920            }
921            _ => &value,
922        };
923        serde_json::from_value(inner.clone())
924    };
925    parse(&s).map_err(|e| wire_err("prompt_json", e))
926}
927
928/// Parse the options JSON (`opts_json`, optional). NULL / empty / `null`
929/// yields the default options.
930fn parse_opts_arg(opts_json: *const c_char) -> FfiResult<GenerateTextOptions> {
931    match opt_str_arg(opts_json, "opts_json")? {
932        Some(s) if !s.trim().is_empty() && s.trim() != "null" => {
933            serde_json::from_str(&s).map_err(|e| wire_err("opts_json", e))
934        }
935        _ => Ok(GenerateTextOptions::default()),
936    }
937}
938
939/// Parse the optional `config_json` argument (`ProviderOptions`); NULL,
940/// empty or "null" means unset.
941fn parse_provider_options(config_json: *const c_char) -> FfiResult<Option<ProviderOptions>> {
942    match opt_str_arg(config_json, "config_json")? {
943        Some(s) if !s.trim().is_empty() && s.trim() != "null" => {
944            serde_json::from_str::<ProviderOptions>(&s)
945                .map(Some)
946                .map_err(|e| wire_err("config_json", e))
947        }
948        _ => Ok(None),
949    }
950}
951
952/// Read a config-style JSON C string and normalize it for lenient
953/// deserialization: NULL, empty, whitespace-only and `"null"` all become
954/// `"{}"` (defaults). Invalid UTF-8 is [`FfiError::InvalidUtf8`].
955fn normalize_config_json(json: *const c_char, argument: &'static str) -> Result<String, FfiError> {
956    Ok(match opt_str_arg(json, argument)? {
957        Some(s) if !s.trim().is_empty() && s.trim() != "null" => s,
958        _ => String::from("{}"),
959    })
960}
961
962/// Build an owned C string (`*mut c_char`) from a `String`, transferring
963/// ownership to the caller (who must free it with [`aimux_free_string`]).
964///
965/// Never returns null (issue M1): interior NUL bytes — which would make
966/// `CString::new` fail — are replaced with U+FFFD so the C-side contract
967/// ("a non-null NUL-terminated buffer to free, or null") always holds. The
968/// replacement is reported through `tracing` (RFC-0014 logging, which C hosts
969/// route via `aimux_init_logging`) so accidental NULs stay visible without
970/// the library writing to the host's stderr directly.
971fn into_cstring_raw(s: String) -> *mut c_char {
972    if s.contains('\0') {
973        tracing::warn!(
974            target: "aimux_ffi",
975            nul_bytes = s.bytes().filter(|&b| b == 0).count(),
976            "string contains NUL byte(s); replaced with U+FFFD before FFI return"
977        );
978    }
979    let sanitized = s.replace('\0', "\u{FFFD}");
980    // No interior NUL remains: this cannot fail.
981    CString::new(sanitized)
982        .expect("aimux-ffi: impossible: NUL-free string rejected by CString::new")
983        .into_raw()
984}
985
986/// Serialize a result to its wire JSON, or [`FfiError::ResultSerialization`].
987fn to_json<T: serde::Serialize>(v: &T) -> FfiResult<String> {
988    serde_json::to_string(v).map_err(|e| {
989        FfiError::ResultSerialization {
990            message: format!("serialize: {e}"),
991        }
992        .into()
993    })
994}
995
996/// Run an async model operation on the runtime and serialize its result.
997fn run_json<F, T>(f: F) -> FfiResult<String>
998where
999    F: std::future::Future<Output = Result<T, AiMuxError>>,
1000    T: serde::Serialize,
1001{
1002    let v = ffi_block_on(f)??;
1003    to_json(&v)
1004}
1005
1006/// Invoke a stream callback (`on_part`/`on_done`) while catching any panic.
1007///
1008/// The callbacks are declared `extern "C-unwind"`, so a *Rust* panic raised
1009/// inside a Rust-implemented callback (panic=unwind builds, same runtime)
1010/// propagates back here instead of aborting at the ABI edge (issue #64); this
1011/// wrapper catches it and converts it to a structured
1012/// [`FfiError::CallbackFailure`] that ends the stream. Foreign exceptions
1013/// (C++, JVM, Swift, Dart, Go) are NOT covered — `catch_unwind` may abort on
1014/// them — so callbacks must not unwind across the C ABI; binding trampolines
1015/// catch their own language's exceptions. (Release builds use `panic =
1016/// "abort"`, in which case the
1017/// process aborts before this point and the code is never produced).
1018///
1019/// `AssertUnwindSafe` is required because the callback receives raw pointers
1020/// (`*const c_char`/`*mut c_void`) that are not `UnwindSafe`; this is sound
1021/// because we abort the stream on any panic rather than continuing.
1022fn invoke_stream_callback(callback_name: &str, f: impl FnOnce()) -> Result<(), FfiError> {
1023    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
1024        Ok(()) => Ok(()),
1025        Err(payload) => {
1026            let msg = payload
1027                .downcast_ref::<&'static str>()
1028                .copied()
1029                .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
1030                .unwrap_or("<non-string panic>");
1031            Err(FfiError::CallbackFailure {
1032                message: format!("stream callback '{callback_name}' panicked: {msg}"),
1033            })
1034        }
1035    }
1036}
1037
1038// ─────────────────────────────────────────────────────────────────────────────
1039// C ABI: provider constructors
1040// ─────────────────────────────────────────────────────────────────────────────
1041
1042/// Create an OpenAI model instance. AiMuxError: invalid model id.
1043#[unsafe(no_mangle)]
1044pub extern "C" fn aimux_openai_new(
1045    api_key: *const c_char,
1046    model_id: *const c_char,
1047    out_handle: *mut u64,
1048) -> *mut aimux_error_t {
1049    with_out_handle(out_handle, || {
1050        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1051        let m = OpenAIProvider::new(OpenAIConfig::new(api_key)).language_model(&model_id)?;
1052        Ok(intern_model(Arc::from(m)))
1053    })
1054}
1055
1056/// Create an OpenAI model instance with a custom base URL.
1057///
1058/// `base_url` may be null (defaults to the provider's standard URL).
1059#[unsafe(no_mangle)]
1060pub extern "C" fn aimux_openai_new_with_base(
1061    api_key: *const c_char,
1062    model_id: *const c_char,
1063    base_url: *const c_char,
1064    out_handle: *mut u64,
1065) -> *mut aimux_error_t {
1066    with_out_handle(out_handle, || {
1067        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1068        let mut config = OpenAIConfig::new(api_key);
1069        if let Some(url) = parse_base_url(base_url)? {
1070            config = config.with_base_url(url);
1071        }
1072        let m = OpenAIProvider::new(config).language_model(&model_id)?;
1073        Ok(intern_model(Arc::from(m)))
1074    })
1075}
1076
1077/// Create an Anthropic model instance. AiMuxError: invalid model id.
1078#[unsafe(no_mangle)]
1079pub extern "C" fn aimux_anthropic_new(
1080    api_key: *const c_char,
1081    model_id: *const c_char,
1082    out_handle: *mut u64,
1083) -> *mut aimux_error_t {
1084    with_out_handle(out_handle, || {
1085        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1086        let m = AnthropicProvider::new(AnthropicConfig::new(api_key)).language_model(&model_id)?;
1087        Ok(intern_model(Arc::from(m)))
1088    })
1089}
1090
1091/// Create an Anthropic model instance with a custom base URL.
1092///
1093/// `base_url` may be null (defaults to the provider's standard URL).
1094#[unsafe(no_mangle)]
1095pub extern "C" fn aimux_anthropic_new_with_base(
1096    api_key: *const c_char,
1097    model_id: *const c_char,
1098    base_url: *const c_char,
1099    out_handle: *mut u64,
1100) -> *mut aimux_error_t {
1101    with_out_handle(out_handle, || {
1102        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1103        let mut config = AnthropicConfig::new(api_key);
1104        if let Some(url) = parse_base_url(base_url)? {
1105            config = config.with_base_url(url);
1106        }
1107        let m = AnthropicProvider::new(config).language_model(&model_id)?;
1108        Ok(intern_model(Arc::from(m)))
1109    })
1110}
1111
1112/// Create an Anthropic-on-AWS model instance (API key + region).
1113#[unsafe(no_mangle)]
1114pub extern "C" fn aimux_anthropic_aws_new(
1115    api_key: *const c_char,
1116    region: *const c_char,
1117    model_id: *const c_char,
1118    out_handle: *mut u64,
1119) -> *mut aimux_error_t {
1120    with_out_handle(out_handle, || {
1121        let api_key = str_arg(api_key, "api_key")?;
1122        let region = str_arg(region, "region")?;
1123        let model_id = str_arg(model_id, "model_id")?;
1124        let m =
1125            AnthropicAwsProvider::new(AnthropicAwsProviderConfig::with_api_key(api_key, region))
1126                .language_model(&model_id)?;
1127        Ok(intern_model(Arc::from(m)))
1128    })
1129}
1130
1131/// Create an Anthropic-on-AWS model instance with a custom base URL.
1132#[unsafe(no_mangle)]
1133pub extern "C" fn aimux_anthropic_aws_new_with_base(
1134    api_key: *const c_char,
1135    region: *const c_char,
1136    model_id: *const c_char,
1137    base_url: *const c_char,
1138    out_handle: *mut u64,
1139) -> *mut aimux_error_t {
1140    with_out_handle(out_handle, || {
1141        let api_key = str_arg(api_key, "api_key")?;
1142        let region = str_arg(region, "region")?;
1143        let model_id = str_arg(model_id, "model_id")?;
1144        let mut config = AnthropicAwsProviderConfig::with_api_key(api_key, region);
1145        if let Some(url) = parse_base_url(base_url)? {
1146            config = config.with_base_url(url);
1147        }
1148        let m = AnthropicAwsProvider::new(config).language_model(&model_id)?;
1149        Ok(intern_model(Arc::from(m)))
1150    })
1151}
1152
1153/// Create an Azure OpenAI model instance (API key + resource name).
1154///
1155/// `api_version` may be null (uses the provider default). The deployment
1156/// name is passed as `model_id`.
1157#[unsafe(no_mangle)]
1158pub extern "C" fn aimux_azure_new(
1159    api_key: *const c_char,
1160    resource_name: *const c_char,
1161    deployment: *const c_char,
1162    api_version: *const c_char,
1163    out_handle: *mut u64,
1164) -> *mut aimux_error_t {
1165    with_out_handle(out_handle, || {
1166        let api_key = str_arg(api_key, "api_key")?;
1167        let resource_name = str_arg(resource_name, "resource_name")?;
1168        let deployment = str_arg(deployment, "deployment")?;
1169        let mut config = AzureConfig::new()
1170            .with_api_key(api_key)
1171            .with_resource_name(resource_name);
1172        if let Some(v) = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()) {
1173            config = config.with_api_version(v);
1174        }
1175        let m = AzureProvider::new(config)?.language_model(&deployment)?;
1176        Ok(intern_model(Arc::from(m)))
1177    })
1178}
1179
1180/// Create an Azure OpenAI model instance with a custom base URL (required,
1181/// in place of `resource_name`).
1182#[unsafe(no_mangle)]
1183pub extern "C" fn aimux_azure_new_with_base(
1184    api_key: *const c_char,
1185    base_url: *const c_char,
1186    deployment: *const c_char,
1187    api_version: *const c_char,
1188    out_handle: *mut u64,
1189) -> *mut aimux_error_t {
1190    with_out_handle(out_handle, || {
1191        let api_key = str_arg(api_key, "api_key")?;
1192        let base_url = str_arg(base_url, "base_url")?;
1193        let deployment = str_arg(deployment, "deployment")?;
1194        let mut config = AzureConfig::new()
1195            .with_api_key(api_key)
1196            .with_base_url(base_url);
1197        if let Some(v) = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()) {
1198            config = config.with_api_version(v);
1199        }
1200        let m = AzureProvider::new(config)?.language_model(&deployment)?;
1201        Ok(intern_model(Arc::from(m)))
1202    })
1203}
1204
1205/// Create a Bedrock model instance (AWS SigV4 credentials).
1206#[unsafe(no_mangle)]
1207pub extern "C" fn aimux_bedrock_new(
1208    access_key_id: *const c_char,
1209    secret_access_key: *const c_char,
1210    region: *const c_char,
1211    model_id: *const c_char,
1212    out_handle: *mut u64,
1213) -> *mut aimux_error_t {
1214    with_out_handle(out_handle, || {
1215        let (access_key_id, secret_access_key, region, model_id) = parse_four_args(
1216            access_key_id,
1217            "access_key_id",
1218            secret_access_key,
1219            "secret_access_key",
1220            region,
1221            "region",
1222            model_id,
1223            "model_id",
1224        )?;
1225        let m = BedrockProvider::new(BedrockProviderConfig::new(
1226            access_key_id,
1227            secret_access_key,
1228            region,
1229        ))
1230        .language_model(&model_id)?;
1231        Ok(intern_model(Arc::from(m)))
1232    })
1233}
1234
1235/// Create a Bedrock model instance with a custom base URL.
1236#[unsafe(no_mangle)]
1237pub extern "C" fn aimux_bedrock_new_with_base(
1238    access_key_id: *const c_char,
1239    secret_access_key: *const c_char,
1240    region: *const c_char,
1241    model_id: *const c_char,
1242    base_url: *const c_char,
1243    out_handle: *mut u64,
1244) -> *mut aimux_error_t {
1245    with_out_handle(out_handle, || {
1246        let (access_key_id, secret_access_key, region, model_id) = parse_four_args(
1247            access_key_id,
1248            "access_key_id",
1249            secret_access_key,
1250            "secret_access_key",
1251            region,
1252            "region",
1253            model_id,
1254            "model_id",
1255        )?;
1256        let mut config = BedrockProviderConfig::new(access_key_id, secret_access_key, region);
1257        if let Some(url) = parse_base_url(base_url)? {
1258            config = config.with_base_url(url);
1259        }
1260        let m = BedrockProvider::new(config).language_model(&model_id)?;
1261        Ok(intern_model(Arc::from(m)))
1262    })
1263}
1264
1265/// Create a Vertex AI model instance (GCP bearer token).
1266#[unsafe(no_mangle)]
1267pub extern "C" fn aimux_vertex_new(
1268    access_token: *const c_char,
1269    project: *const c_char,
1270    location: *const c_char,
1271    model_id: *const c_char,
1272    out_handle: *mut u64,
1273) -> *mut aimux_error_t {
1274    with_out_handle(out_handle, || {
1275        let (access_token, project, location, model_id) = parse_four_args(
1276            access_token,
1277            "access_token",
1278            project,
1279            "project",
1280            location,
1281            "location",
1282            model_id,
1283            "model_id",
1284        )?;
1285        let m = VertexProvider::new(VertexProviderConfig::new(access_token, project, location))
1286            .language_model(&model_id)?;
1287        Ok(intern_model(Arc::from(m)))
1288    })
1289}
1290
1291/// Create a Vertex AI model instance with a custom base URL.
1292#[unsafe(no_mangle)]
1293pub extern "C" fn aimux_vertex_new_with_base(
1294    access_token: *const c_char,
1295    project: *const c_char,
1296    location: *const c_char,
1297    model_id: *const c_char,
1298    base_url: *const c_char,
1299    out_handle: *mut u64,
1300) -> *mut aimux_error_t {
1301    with_out_handle(out_handle, || {
1302        let (access_token, project, location, model_id) = parse_four_args(
1303            access_token,
1304            "access_token",
1305            project,
1306            "project",
1307            location,
1308            "location",
1309            model_id,
1310            "model_id",
1311        )?;
1312        let mut config = VertexProviderConfig::new(access_token, project, location);
1313        if let Some(url) = parse_base_url(base_url)? {
1314            config = config.with_base_url(url);
1315        }
1316        let m = VertexProvider::new(config).language_model(&model_id)?;
1317        Ok(intern_model(Arc::from(m)))
1318    })
1319}
1320
1321/// Create a Cohere model instance. AiMuxError: invalid model id.
1322#[unsafe(no_mangle)]
1323pub extern "C" fn aimux_cohere_new(
1324    api_key: *const c_char,
1325    model_id: *const c_char,
1326    out_handle: *mut u64,
1327) -> *mut aimux_error_t {
1328    with_out_handle(out_handle, || {
1329        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1330        let m = CohereProvider::new(CohereConfig::new(api_key)).language_model(&model_id)?;
1331        Ok(intern_model(Arc::from(m)))
1332    })
1333}
1334
1335/// Create a Cohere model instance with a custom base URL.
1336#[unsafe(no_mangle)]
1337pub extern "C" fn aimux_cohere_new_with_base(
1338    api_key: *const c_char,
1339    model_id: *const c_char,
1340    base_url: *const c_char,
1341    out_handle: *mut u64,
1342) -> *mut aimux_error_t {
1343    with_out_handle(out_handle, || {
1344        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1345        let mut config = CohereConfig::new(api_key);
1346        if let Some(url) = parse_base_url(base_url)? {
1347            config = config.with_base_url(url);
1348        }
1349        let m = CohereProvider::new(config).language_model(&model_id)?;
1350        Ok(intern_model(Arc::from(m)))
1351    })
1352}
1353
1354/// Create a Mistral model instance. AiMuxError: invalid model id.
1355#[unsafe(no_mangle)]
1356pub extern "C" fn aimux_mistral_new(
1357    api_key: *const c_char,
1358    model_id: *const c_char,
1359    out_handle: *mut u64,
1360) -> *mut aimux_error_t {
1361    with_out_handle(out_handle, || {
1362        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1363        let m = MistralProvider::new(MistralConfig::new(api_key)).language_model(&model_id)?;
1364        Ok(intern_model(Arc::from(m)))
1365    })
1366}
1367
1368/// Create a Mistral model instance with a custom base URL.
1369#[unsafe(no_mangle)]
1370pub extern "C" fn aimux_mistral_new_with_base(
1371    api_key: *const c_char,
1372    model_id: *const c_char,
1373    base_url: *const c_char,
1374    out_handle: *mut u64,
1375) -> *mut aimux_error_t {
1376    with_out_handle(out_handle, || {
1377        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1378        let mut config = MistralConfig::new(api_key);
1379        if let Some(url) = parse_base_url(base_url)? {
1380            config = config.with_base_url(url);
1381        }
1382        let m = MistralProvider::new(config).language_model(&model_id)?;
1383        Ok(intern_model(Arc::from(m)))
1384    })
1385}
1386
1387/// Create an xAI model instance. AiMuxError: invalid model id.
1388#[unsafe(no_mangle)]
1389pub extern "C" fn aimux_xai_new(
1390    api_key: *const c_char,
1391    model_id: *const c_char,
1392    out_handle: *mut u64,
1393) -> *mut aimux_error_t {
1394    with_out_handle(out_handle, || {
1395        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1396        let m = XAIProvider::new(XAIConfig::new(api_key)).language_model(&model_id)?;
1397        Ok(intern_model(Arc::from(m)))
1398    })
1399}
1400
1401/// Create an xAI model instance with a custom base URL.
1402#[unsafe(no_mangle)]
1403pub extern "C" fn aimux_xai_new_with_base(
1404    api_key: *const c_char,
1405    model_id: *const c_char,
1406    base_url: *const c_char,
1407    out_handle: *mut u64,
1408) -> *mut aimux_error_t {
1409    with_out_handle(out_handle, || {
1410        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
1411        let mut config = XAIConfig::new(api_key);
1412        if let Some(url) = parse_base_url(base_url)? {
1413            config = config.with_base_url(url);
1414        }
1415        let m = XAIProvider::new(config).language_model(&model_id)?;
1416        Ok(intern_model(Arc::from(m)))
1417    })
1418}
1419
1420/// Create a language model from the registry by provider name (RFC-0017 phase 4).
1421///
1422/// - `name` — registry provider name, e.g. `"groq"` / `"deepseek"`.
1423/// - `api_key` — may be NULL to read the provider's env var from the registry
1424///   entry (replaces the retired `aimux_deepseek_new` etc.).
1425/// - `model_id` — model id string.
1426/// - `config_json` — optional JSON object of `ProviderOptions`
1427///   (`{"base_url": "...", "headers": {...}, "max_retries": 0, "body_overrides": {...}}`);
1428///   NULL / empty / "null" for defaults.
1429///
1430/// AiMuxError: unknown provider, bad config shape, missing env key, or
1431/// invalid model id.
1432#[unsafe(no_mangle)]
1433pub extern "C" fn aimux_provider_new(
1434    name: *const c_char,
1435    api_key: *const c_char,
1436    model_id: *const c_char,
1437    config_json: *const c_char,
1438    out_handle: *mut u64,
1439) -> *mut aimux_error_t {
1440    with_out_handle(out_handle, || {
1441        let name = str_arg(name, "name")?;
1442        let model_id = str_arg(model_id, "model_id")?;
1443        // NULL => the registry entry's env var; a non-NULL key must be UTF-8.
1444        let key = opt_str_arg(api_key, "api_key")?;
1445        let opts = parse_provider_options(config_json)?;
1446        let m = provider(&name, key, &model_id, opts)?;
1447        Ok(intern_model(Arc::from(m)))
1448    })
1449}
1450
1451/// Convenience: create a language model by provider name, reading the API key
1452/// from the provider's env var.
1453#[unsafe(no_mangle)]
1454pub extern "C" fn aimux_provider_from_env(
1455    name: *const c_char,
1456    model_id: *const c_char,
1457    out_handle: *mut u64,
1458) -> *mut aimux_error_t {
1459    with_out_handle(out_handle, || {
1460        let name = str_arg(name, "name")?;
1461        let model_id = str_arg(model_id, "model_id")?;
1462        let m = provider(&name, None, &model_id, None)?;
1463        Ok(intern_model(Arc::from(m)))
1464    })
1465}
1466
1467// ─────────────────────────────────────────────────────────────────────────────
1468// C ABI: provider handles (RFC-0027) — createProvider / listModels / model
1469// ─────────────────────────────────────────────────────────────────────────────
1470
1471/// Create a **provider handle** (RFC-0027) for a registry-backed provider.
1472///
1473/// Unlike `aimux_provider_new` (which binds to a single model_id), this returns
1474/// a provider handle that supports `aimux_provider_list_models` (runtime
1475/// discovery) and `aimux_provider_model` (build a model from a discovered id).
1476///
1477/// `api_key = null` reads the provider's env var from the registry entry.
1478/// `config_json` is an optional `ProviderOptions` JSON string (same as
1479/// `aimux_provider_new`).
1480#[unsafe(no_mangle)]
1481pub extern "C" fn aimux_provider_handle_new(
1482    name: *const c_char,
1483    api_key: *const c_char,
1484    config_json: *const c_char,
1485    out_handle: *mut u64,
1486) -> *mut aimux_error_t {
1487    with_out_handle(out_handle, || {
1488        let name = str_arg(name, "name")?;
1489        let key = opt_str_arg(api_key, "api_key")?;
1490        let opts = parse_provider_options(config_json)?;
1491        let p = provider_handle(&name, key, opts)?;
1492        Ok(intern_handle(HandleEntry::Provider(Arc::from(p))))
1493    })
1494}
1495
1496/// List models on a provider handle (RFC-0027 runtime discovery).
1497///
1498/// `handle` is from `aimux_provider_handle_new`. Writes a JSON array of
1499/// sparse `RuntimeModel` (id / owned_by / created) — **no community
1500/// enrichment**. To supplement with model specs, call `aimux_get_model_specs`
1501/// separately and merge in the host.
1502#[unsafe(no_mangle)]
1503pub extern "C" fn aimux_provider_list_models(
1504    handle: u64,
1505    out_models_json: *mut *mut c_char,
1506) -> *mut aimux_error_t {
1507    with_out_string(out_models_json, "out_models_json", || {
1508        let HandleEntry::Provider(p) = entry_of(handle, "provider")? else {
1509            return Err(FfiError::InvalidHandle {
1510                expected: "provider",
1511            }
1512            .into());
1513        };
1514        run_json(p.list_models())
1515    })
1516}
1517
1518/// Build a language model from a provider handle + model_id (RFC-0027).
1519///
1520/// `handle` is from `aimux_provider_handle_new`. The new handle is a model
1521/// handle (same as `aimux_provider_new`), usable with `aimux_generate_text`
1522/// etc.
1523#[unsafe(no_mangle)]
1524pub extern "C" fn aimux_provider_model(
1525    handle: u64,
1526    model_id: *const c_char,
1527    out_handle: *mut u64,
1528) -> *mut aimux_error_t {
1529    with_out_handle(out_handle, || {
1530        let HandleEntry::Provider(p) = entry_of(handle, "provider")? else {
1531            return Err(FfiError::InvalidHandle {
1532                expected: "provider",
1533            }
1534            .into());
1535        };
1536        let model_id = str_arg(model_id, "model_id")?;
1537        let m = p.language_model(&model_id)?;
1538        Ok(intern_model(Arc::from(m)))
1539    })
1540}
1541
1542// ─────────────────────────────────────────────────────────────────────────────
1543// C ABI: model specs (RFC-0027) — get_model_specs
1544// ─────────────────────────────────────────────────────────────────────────────
1545
1546/// Fetch the community model catalogue (anya2a). Writes a JSON-serialized
1547/// `Catalogue` (provider → model_id → ModelSpec).
1548///
1549/// `source_url` is an optional URL override (null = default anya2a endpoint).
1550/// This is a **thin fetch** — no caching, no FS writes. The host decides how
1551/// to cache/persist the result.
1552#[unsafe(no_mangle)]
1553pub extern "C" fn aimux_get_model_specs(
1554    source_url: *const c_char,
1555    out_specs_json: *mut *mut c_char,
1556) -> *mut aimux_error_t {
1557    with_out_string(out_specs_json, "out_specs_json", || {
1558        let url = opt_str_arg(source_url, "source_url")?;
1559        run_json(aimux_providers::get_model_specs(url.as_deref()))
1560    })
1561}
1562
1563// ─────────────────────────────────────────────────────────────────────────────
1564// C ABI: non-streaming generation
1565// ─────────────────────────────────────────────────────────────────────────────
1566
1567/// Non-streaming generation.
1568///
1569/// `prompt_json` is either a bare prompt value (`"text"` or a messages array)
1570/// or `{"prompt": <value>}`. `opts_json` is a serialized `GenerateTextOptions`
1571/// (empty / null for defaults). Writes the serialized `GenerateTextResult`
1572/// to `*out_json` (caller frees with [`aimux_free_string`]).
1573#[unsafe(no_mangle)]
1574pub extern "C" fn aimux_generate_text(
1575    handle: u64,
1576    prompt_json: *const c_char,
1577    opts_json: *const c_char,
1578    out_json: *mut *mut c_char,
1579) -> *mut aimux_error_t {
1580    with_out_string(out_json, "out_json", || {
1581        let model = model_of(handle)?;
1582        let prompt = parse_prompt_arg(prompt_json)?;
1583        let opts = parse_opts_arg(opts_json)?;
1584        run_json(async move { generate_text(&*model, prompt, opts).await })
1585    })
1586}
1587
1588/// Generate a structured JSON object from the model (M12, RFC-0016).
1589///
1590/// Same signature as [`aimux_generate_text`]; writes the serialized
1591/// `GenerateObjectResult`. The caller passes `response_format: { "Json": {
1592/// ... } }` via `opts_json` to control the schema; the function applies JSON
1593/// repair before parsing.
1594#[unsafe(no_mangle)]
1595pub extern "C" fn aimux_generate_object(
1596    handle: u64,
1597    prompt_json: *const c_char,
1598    opts_json: *const c_char,
1599    out_json: *mut *mut c_char,
1600) -> *mut aimux_error_t {
1601    with_out_string(out_json, "out_json", || {
1602        let model = model_of(handle)?;
1603        let prompt = parse_prompt_arg(prompt_json)?;
1604        let opts = parse_opts_arg(opts_json)?;
1605        run_json(async move { generate_object(&*model, prompt, opts).await })
1606    })
1607}
1608
1609/// Consume a stream to completion and write the aggregated result (M11,
1610/// RFC-0016). Synchronous — blocks until the stream finishes.
1611///
1612/// Same signature as [`aimux_generate_text`]; writes the serialized
1613/// `StreamTextResultAggregated`.
1614#[unsafe(no_mangle)]
1615pub extern "C" fn aimux_consume_stream_text(
1616    handle: u64,
1617    prompt_json: *const c_char,
1618    opts_json: *const c_char,
1619    out_json: *mut *mut c_char,
1620) -> *mut aimux_error_t {
1621    with_out_string(out_json, "out_json", || {
1622        let model = model_of(handle)?;
1623        let prompt = parse_prompt_arg(prompt_json)?;
1624        let opts = parse_opts_arg(opts_json)?;
1625        run_json(async move {
1626            let stream_result = stream_text(&*model, prompt, opts).await?;
1627            stream_result.consume().await
1628        })
1629    })
1630}
1631
1632// ─────────────────────────────────────────────────────────────────────────────
1633// C ABI: streaming generation (push callbacks)
1634// ─────────────────────────────────────────────────────────────────────────────
1635
1636/// Streaming generation with push callbacks.
1637///
1638/// Blocks the calling thread until the stream completes (synchronous +
1639/// callback mode). Callbacks are invoked in the same call stack:
1640/// - `on_part(json, stream_ctx)`: each `StreamPart` as JSON (valid only during
1641///   the call). `StreamPart::Error` is data on this path, not a terminal fail.
1642/// - `on_done(stream_ctx)`: once on normal completion (not on failure).
1643///
1644/// NULL on success (after `on_done`); a returned error otherwise (no
1645/// `on_done`): a NULL callback is `NullPointer`, a provider failure is the
1646/// model's, a part that cannot be serialized is `ResultSerialization`.
1647#[unsafe(no_mangle)]
1648pub extern "C" fn aimux_stream_text(
1649    handle: u64,
1650    prompt_json: *const c_char,
1651    opts_json: *const c_char,
1652    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
1653    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
1654    stream_ctx: *mut c_void,
1655) -> *mut aimux_error_t {
1656    no_result(|| {
1657        stream_text_with_signal(
1658            handle,
1659            prompt_json,
1660            opts_json,
1661            on_part,
1662            on_done,
1663            stream_ctx,
1664            None,
1665        )
1666    })
1667}
1668
1669/// Create a per-call abort signal for a cancelable FFI operation.
1670///
1671/// The caller must release the returned handle with
1672/// [`aimux_abort_signal_drop`].
1673#[unsafe(no_mangle)]
1674pub extern "C" fn aimux_abort_signal_new() -> u64 {
1675    intern_handle(HandleEntry::Abort(AbortSignal::new()))
1676}
1677
1678/// Request cancellation for an abort-signal handle.
1679///
1680/// Invalid handles and repeated calls are no-ops.
1681#[unsafe(no_mangle)]
1682pub extern "C" fn aimux_abort_signal_abort(handle: u64) {
1683    if let Some(signal) = get_abort_signal(handle) {
1684        signal.abort();
1685    }
1686}
1687
1688/// Release an abort-signal handle.
1689///
1690/// This function does not remove model handles if the caller passes the wrong
1691/// handle type. Active calls keep their cloned signal alive.
1692#[unsafe(no_mangle)]
1693pub extern "C" fn aimux_abort_signal_drop(handle: u64) {
1694    if handle != 0 {
1695        drop_abort_signal(handle);
1696    }
1697}
1698
1699/// Stream text with a per-call abort signal.
1700///
1701/// This function blocks like [`aimux_stream_text`]. Another thread can call
1702/// [`aimux_abort_signal_abort`] with `abort_handle` to stop this call
1703/// (`AiMuxError::Aborted`, no `on_done`).
1704#[unsafe(no_mangle)]
1705pub extern "C" fn aimux_stream_text_with_abort(
1706    handle: u64,
1707    abort_handle: u64,
1708    prompt_json: *const c_char,
1709    opts_json: *const c_char,
1710    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
1711    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
1712    stream_ctx: *mut c_void,
1713) -> *mut aimux_error_t {
1714    no_result(|| {
1715        let abort_signal = abort_of(abort_handle)?;
1716        stream_text_with_signal(
1717            handle,
1718            prompt_json,
1719            opts_json,
1720            on_part,
1721            on_done,
1722            stream_ctx,
1723            Some(abort_signal),
1724        )
1725    })
1726}
1727
1728// ─────────────────────────────────────────────────────────────────────────────
1729// C ABI: host-side tool-call repair (RFC-0035)
1730// ─────────────────────────────────────────────────────────────────────────────
1731
1732/// Build the repair argument for one invalid tool call (RFC-0035).
1733///
1734/// `tool_call_json` is one entry of `GenerateTextResult.tool_calls` with
1735/// `"invalid": true`. `prompt_json` and `opts_json` are **the same two
1736/// strings the call was generated with** ([`aimux_generate_text`] /
1737/// [`aimux_stream_text`]); messages, instructions, and the tool set are
1738/// derived from them here, so no host repeats that derivation.
1739///
1740/// Writes `{tool_call, error, input_schema, tools, messages, instructions}` —
1741/// the AI SDK `repairToolCall` argument — to `*out_json`, or the JSON literal
1742/// `null` when `opts_json` carried no tools: as in the AI SDK, a call made
1743/// without a tool set is never repaired, and the host skips it. `null` is a
1744/// success, not an error.
1745///
1746/// Pure: no model handle, no runtime, no I/O. Safe to call from inside a
1747/// stream callback (the re-entrancy guard only covers runtime `block_on`).
1748#[unsafe(no_mangle)]
1749pub extern "C" fn aimux_tool_call_repair_context(
1750    tool_call_json: *const c_char,
1751    prompt_json: *const c_char,
1752    opts_json: *const c_char,
1753    out_json: *mut *mut c_char,
1754) -> *mut aimux_error_t {
1755    with_out_string(out_json, "out_json", || {
1756        let tool_call: ToolCall = parse_json_arg(tool_call_json, "tool_call_json")?;
1757        let prompt = parse_prompt_arg(prompt_json)?;
1758        let opts = parse_opts_arg(opts_json)?;
1759        let (messages, instructions, tools) = tool_call_repair_inputs(prompt, &opts);
1760        to_json(&tool_call_repair_context(
1761            &tool_call,
1762            tools,
1763            &messages,
1764            instructions,
1765        )?)
1766    })
1767}
1768
1769/// Resolve one invalid tool call against a host's repair reply (RFC-0035).
1770///
1771/// `opts_json` is the same string the call was generated with; the tool set
1772/// comes from it. `reply_json` is `{"type":"repaired","tool_call":{…}}`,
1773/// `{"type":"unchanged"}`, or `{"type":"failed","message":"…"}`. Writes the
1774/// resulting `ToolCall` — valid, or invalid with the AI SDK's nested
1775/// `ToolCallRepairError` — to `*out_json`. Same purity as
1776/// [`aimux_tool_call_repair_context`].
1777///
1778/// Options carrying no tools are `AIMUX_E_INVALID_ARGUMENT`: such a call is
1779/// not repairable, and `aimux_tool_call_repair_context` already said so by
1780/// writing `null`.
1781#[unsafe(no_mangle)]
1782pub extern "C" fn aimux_apply_tool_call_repair(
1783    tool_call_json: *const c_char,
1784    opts_json: *const c_char,
1785    reply_json: *const c_char,
1786    out_json: *mut *mut c_char,
1787) -> *mut aimux_error_t {
1788    with_out_string(out_json, "out_json", || {
1789        let tool_call: ToolCall = parse_json_arg(tool_call_json, "tool_call_json")?;
1790        let opts = parse_opts_arg(opts_json)?;
1791        let reply: ToolCallRepairReply = parse_json_arg(reply_json, "reply_json")?;
1792        to_json(&apply_tool_call_repair(
1793            &tool_call,
1794            opts.tools.as_deref(),
1795            reply,
1796        )?)
1797    })
1798}
1799
1800/// Apply a repair reply to a whole result document (RFC-0035).
1801///
1802/// `result_json` is a serialized `GenerateTextResult` or
1803/// `GenerateObjectResult`; `opts_json` is the same string the call was
1804/// generated with. Both `tool_calls` and the matching `response_messages`
1805/// tool-call part are rewritten. The OpenAI-shaped
1806/// `ChatCompletion` is not supported — it carries no `invalid`/`error`, so
1807/// repair is driven from the native result, which
1808/// [`aimux_generate_text_result_as_openai`] then converts. Same purity as
1809/// [`aimux_tool_call_repair_context`].
1810#[unsafe(no_mangle)]
1811pub extern "C" fn aimux_apply_tool_call_repair_to_result(
1812    result_json: *const c_char,
1813    opts_json: *const c_char,
1814    tool_call_id: *const c_char,
1815    reply_json: *const c_char,
1816    out_json: *mut *mut c_char,
1817) -> *mut aimux_error_t {
1818    with_out_string(out_json, "out_json", || {
1819        let result: serde_json::Value = parse_json_arg(result_json, "result_json")?;
1820        let opts = parse_opts_arg(opts_json)?;
1821        let tool_call_id = str_arg(tool_call_id, "tool_call_id")?;
1822        let reply: ToolCallRepairReply = parse_json_arg(reply_json, "reply_json")?;
1823        to_json(&apply_tool_call_repair_to_result(
1824            &result,
1825            opts.tools.as_deref(),
1826            &tool_call_id,
1827            reply,
1828        )?)
1829    })
1830}
1831
1832// ─────────────────────────────────────────────────────────────────────────────
1833// C ABI: OpenAI-compatible output (RFC-0026)
1834// ─────────────────────────────────────────────────────────────────────────────
1835
1836/// Convert a serialized `GenerateTextResult` into a serialized `ChatCompletion`.
1837///
1838/// The conversion half of [`aimux_generate_text_as_openai`], for hosts that
1839/// repair a native result themselves (RFC-0035) and then want the OpenAI
1840/// shape: tool calls come from `result_json`'s `tool_calls`, so a patched
1841/// result converts to a completion that reflects the repair. `handle` only
1842/// supplies the model id fallback for `ChatCompletion.model`; no runtime work,
1843/// so this is safe from inside a stream callback.
1844#[unsafe(no_mangle)]
1845pub extern "C" fn aimux_generate_text_result_as_openai(
1846    handle: u64,
1847    result_json: *const c_char,
1848    out_json: *mut *mut c_char,
1849) -> *mut aimux_error_t {
1850    with_out_string(out_json, "out_json", || {
1851        let model = model_of(handle)?;
1852        let result: GenerateTextResult = parse_json_arg(result_json, "result_json")?;
1853        to_json(&generate_text_result_to_chat_completion(
1854            &result,
1855            model.model_id(),
1856        ))
1857    })
1858}
1859
1860/// Non-streaming text generation with OpenAI Chat Completions output.
1861///
1862/// Identical to [`aimux_generate_text`] except the written JSON string is a
1863/// serialized `ChatCompletion` (OpenAI `chat.completion` object) rather than a
1864/// `GenerateTextResult`. Works with any provider — the result is always
1865/// standard OpenAI format.
1866#[unsafe(no_mangle)]
1867pub extern "C" fn aimux_generate_text_as_openai(
1868    handle: u64,
1869    prompt_json: *const c_char,
1870    opts_json: *const c_char,
1871    out_json: *mut *mut c_char,
1872) -> *mut aimux_error_t {
1873    with_out_string(out_json, "out_json", || {
1874        let model = model_of(handle)?;
1875        let prompt = parse_prompt_arg(prompt_json)?;
1876        let opts = parse_opts_arg(opts_json)?;
1877        run_json(async move { generate_text_as_openai(&*model, prompt, opts).await })
1878    })
1879}
1880
1881/// Streaming text generation with OpenAI Chat Completions output.
1882///
1883/// Identical to [`aimux_stream_text`] except each `on_part` callback receives a
1884/// serialized `ChatCompletionChunk` (OpenAI `chat.completion.chunk` object)
1885/// rather than a `StreamPart`. Works with any provider.
1886///
1887/// `opts_json` may carry an extra `openai_stream_options` object with
1888/// `include_usage` (bool, default true) and `include_reasoning` (bool, default
1889/// true) fields to control the chunk output.
1890///
1891/// `StreamPart::Error` is mapped to a content delta + finish chunk; terminal
1892/// failures return a returned error (same polarity as [`aimux_stream_text`]).
1893#[unsafe(no_mangle)]
1894pub extern "C" fn aimux_stream_text_as_openai(
1895    handle: u64,
1896    prompt_json: *const c_char,
1897    opts_json: *const c_char,
1898    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
1899    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
1900    stream_ctx: *mut c_void,
1901) -> *mut aimux_error_t {
1902    no_result(|| {
1903        stream_text_as_openai_with_signal(
1904            handle,
1905            prompt_json,
1906            opts_json,
1907            on_part,
1908            on_done,
1909            stream_ctx,
1910            None,
1911        )
1912    })
1913}
1914
1915/// Cancelable streaming OpenAI-compatible output (see
1916/// [`aimux_stream_text_with_abort`]).
1917#[unsafe(no_mangle)]
1918pub extern "C" fn aimux_stream_text_as_openai_with_abort(
1919    handle: u64,
1920    abort_handle: u64,
1921    prompt_json: *const c_char,
1922    opts_json: *const c_char,
1923    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
1924    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
1925    stream_ctx: *mut c_void,
1926) -> *mut aimux_error_t {
1927    no_result(|| {
1928        let abort_signal = abort_of(abort_handle)?;
1929        stream_text_as_openai_with_signal(
1930            handle,
1931            prompt_json,
1932            opts_json,
1933            on_part,
1934            on_done,
1935            stream_ctx,
1936            Some(abort_signal),
1937        )
1938    })
1939}
1940
1941/// Serialize a stream item for `on_part`, or [`FfiError::ResultSerialization`].
1942fn stream_part_cstring<T: serde::Serialize>(part: &T) -> Result<CString, FfiError> {
1943    let json = serde_json::to_string(part).map_err(|e| FfiError::ResultSerialization {
1944        message: format!("stream part: {e}"),
1945    })?;
1946    CString::new(json).map_err(|e| FfiError::ResultSerialization {
1947        message: format!("stream part contains NUL: {e}"),
1948    })
1949}
1950
1951fn stream_text_as_openai_with_signal(
1952    handle: u64,
1953    prompt_json: *const c_char,
1954    opts_json: *const c_char,
1955    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
1956    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
1957    stream_ctx: *mut c_void,
1958    abort_signal: Option<AbortSignal>,
1959) -> FfiResult<()> {
1960    // A NULL callback is this layer's finding, before anything else runs.
1961    let on_part = on_part.ok_or(FfiError::NullPointer {
1962        argument: "on_part",
1963    })?;
1964    let on_done = on_done.ok_or(FfiError::NullPointer {
1965        argument: "on_done",
1966    })?;
1967    let model = model_of(handle)?;
1968    let prompt = parse_prompt_arg(prompt_json)?;
1969    let mut opts = parse_opts_arg(opts_json)?;
1970
1971    // Extract OpenAI stream options from the opts JSON, then remove them so
1972    // they don't confuse the provider (which doesn't know this field).
1973    let stream_options = opts
1974        .provider_options
1975        .as_ref()
1976        .and_then(|po| po.get("openai"))
1977        .and_then(|o| o.get("stream_options"))
1978        .cloned()
1979        .map(|v| OpenAiStreamOptions {
1980            include_usage: v
1981                .get("include_usage")
1982                .and_then(serde_json::Value::as_bool)
1983                .unwrap_or(true),
1984            include_reasoning: v
1985                .get("include_reasoning")
1986                .and_then(serde_json::Value::as_bool)
1987                .unwrap_or(true),
1988        })
1989        .unwrap_or_default();
1990
1991    opts.abort_signal = abort_signal.clone();
1992    // stream_ctx is only for C callbacks; not Send into the async task.
1993    let stream_ctx = stream_ctx as usize;
1994
1995    ffi_block_on(async move {
1996        let sr = match abort_signal.as_ref() {
1997            Some(signal) => {
1998                tokio::select! {
1999                    biased;
2000                    _ = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
2001                    result = stream_text_as_openai(&*model, prompt, opts, stream_options) => result,
2002                }
2003            }
2004            None => stream_text_as_openai(&*model, prompt, opts, stream_options).await,
2005        }?;
2006        let mut stream = sr.stream;
2007        loop {
2008            let next = match abort_signal.as_ref() {
2009                Some(signal) => {
2010                    tokio::select! {
2011                        biased;
2012                        _ = signal.cancelled() => {
2013                            return Err(AiMuxError::from_abort_signal(signal).into());
2014                        }
2015                        item = stream.next() => item,
2016                    }
2017                }
2018                None => stream.next().await,
2019            };
2020            let Some(item) = next else {
2021                break;
2022            };
2023            // A chunk that cannot be serialized ends the stream with this
2024            // layer's ResultSerialization — never a silent "{}" placeholder.
2025            // Core yields a malformed frame as a recoverable Err and keeps the
2026            // stream alive. Every consumer of this path types items as
2027            // ChatCompletionChunk, so the error cannot ride this wire — skip
2028            // it and keep pumping (full fidelity lives on the plain
2029            // StreamPart path).
2030            let cstr = match item {
2031                Ok(chunk) => stream_part_cstring(&chunk)?,
2032                Err(e) if e.is_recoverable_stream_error() => continue,
2033                Err(e) => return Err(e.into()),
2034            };
2035            invoke_stream_callback("on_part", || {
2036                on_part(cstr.as_ptr(), stream_ctx as *mut c_void);
2037            })?;
2038        }
2039        invoke_stream_callback("on_done", || {
2040            on_done(stream_ctx as *mut c_void);
2041        })?;
2042        Ok::<(), AiMuxFfiError>(())
2043    })?
2044}
2045
2046fn stream_text_with_signal(
2047    handle: u64,
2048    prompt_json: *const c_char,
2049    opts_json: *const c_char,
2050    on_part: Option<extern "C-unwind" fn(*const c_char, *mut c_void)>,
2051    on_done: Option<extern "C-unwind" fn(*mut c_void)>,
2052    stream_ctx: *mut c_void,
2053    abort_signal: Option<AbortSignal>,
2054) -> FfiResult<()> {
2055    // A NULL callback is this layer's finding, before anything else runs.
2056    let on_part = on_part.ok_or(FfiError::NullPointer {
2057        argument: "on_part",
2058    })?;
2059    let on_done = on_done.ok_or(FfiError::NullPointer {
2060        argument: "on_done",
2061    })?;
2062    let model = model_of(handle)?;
2063    let prompt = parse_prompt_arg(prompt_json)?;
2064    let mut opts = parse_opts_arg(opts_json)?;
2065    opts.abort_signal = abort_signal.clone();
2066    let stream_ctx = stream_ctx as usize;
2067
2068    ffi_block_on(async move {
2069        let sr = match abort_signal.as_ref() {
2070            Some(signal) => {
2071                tokio::select! {
2072                    biased;
2073                    _ = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
2074                    result = stream_text(&*model, prompt, opts) => result,
2075                }
2076            }
2077            None => stream_text(&*model, prompt, opts).await,
2078        }?;
2079        let mut stream = sr.stream;
2080        loop {
2081            let next = match abort_signal.as_ref() {
2082                Some(signal) => {
2083                    tokio::select! {
2084                        biased;
2085                        _ = signal.cancelled() => {
2086                            return Err(AiMuxError::from_abort_signal(signal).into());
2087                        }
2088                        item = stream.next() => item,
2089                    }
2090                }
2091                None => stream.next().await,
2092            };
2093            let Some(item) = next else {
2094                break;
2095            };
2096            // A part that cannot be serialized ends the stream with this
2097            // layer's ResultSerialization — never a silent "{}" placeholder.
2098            // Core keeps the stream alive across a malformed frame; mirror it
2099            // by delivering the error as a StreamPart::Error data frame (the
2100            // documented non-terminal shape on this path) and keep pumping.
2101            let cstr = match item {
2102                Ok(part) => stream_part_cstring(&part)?,
2103                Err(e) if e.is_recoverable_stream_error() => {
2104                    stream_part_cstring(&aimux_core::stream_part::StreamPart::Error { error: e })?
2105                }
2106                Err(e) => return Err(e.into()),
2107            };
2108            invoke_stream_callback("on_part", || {
2109                on_part(cstr.as_ptr(), stream_ctx as *mut c_void);
2110            })?;
2111        }
2112        invoke_stream_callback("on_done", || {
2113            on_done(stream_ctx as *mut c_void);
2114        })?;
2115        Ok::<(), AiMuxFfiError>(())
2116    })?
2117}
2118
2119// ─────────────────────────────────────────────────────────────────────────────
2120// C ABI: resource management
2121// ─────────────────────────────────────────────────────────────────────────────
2122
2123/// Release a handle previously returned by `aimux_*_new` (any handle type — a
2124/// transcription session's driver task is aborted and joined as well, so
2125/// `aimux_transcription_session_drop` is only a clearer name for the same
2126/// thing).
2127///
2128/// Safe to call with `0` or an unknown handle (no-op).
2129#[unsafe(no_mangle)]
2130pub extern "C" fn aimux_drop_handle(handle: u64) {
2131    if handle != 0 {
2132        drop_handle(handle);
2133    }
2134}
2135
2136/// Free a C string previously returned by any aimux function that hands out
2137/// `*mut c_char` — result JSON, error getters.
2138///
2139/// # Safety
2140///
2141/// `ptr` must be null or a pointer previously produced by an aimux `char*`
2142/// return (i.e. via `CString::into_raw`). Passing any other pointer is
2143/// undefined behavior.
2144#[unsafe(no_mangle)]
2145pub unsafe extern "C" fn aimux_free_string(ptr: *mut c_char) {
2146    if ptr.is_null() {
2147        return;
2148    }
2149    // SAFETY: caller guarantees `ptr` came from `CString::into_raw`.
2150    drop(unsafe { CString::from_raw(ptr) });
2151}
2152
2153// ─────────────────────────────────────────────────────────────────────────────
2154// C ABI: Embedding
2155// ─────────────────────────────────────────────────────────────────────────────
2156
2157/// Create an OpenAI embedding model instance.
2158#[unsafe(no_mangle)]
2159pub extern "C" fn aimux_openai_embedding_new(
2160    api_key: *const c_char,
2161    model_id: *const c_char,
2162    out_handle: *mut u64,
2163) -> *mut aimux_error_t {
2164    with_out_handle(out_handle, || {
2165        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2166        let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).embedding_model(&model_id);
2167        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2168    })
2169}
2170
2171#[unsafe(no_mangle)]
2172pub extern "C" fn aimux_openai_embedding_new_with_base(
2173    api_key: *const c_char,
2174    model_id: *const c_char,
2175    base_url: *const c_char,
2176    out_handle: *mut u64,
2177) -> *mut aimux_error_t {
2178    with_out_handle(out_handle, || {
2179        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2180        let mut config = OpenAIConfig::new(api_key);
2181        if let Some(url) = parse_base_url(base_url)? {
2182            config = config.with_base_url(url);
2183        }
2184        let model = OpenAIProvider::new(config).embedding_model(&model_id);
2185        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2186    })
2187}
2188
2189#[unsafe(no_mangle)]
2190pub extern "C" fn aimux_cohere_embedding_new(
2191    api_key: *const c_char,
2192    model_id: *const c_char,
2193    out_handle: *mut u64,
2194) -> *mut aimux_error_t {
2195    with_out_handle(out_handle, || {
2196        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2197        let model = CohereProvider::new(CohereConfig::new(api_key)).embedding_model(&model_id);
2198        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2199    })
2200}
2201
2202#[unsafe(no_mangle)]
2203pub extern "C" fn aimux_cohere_embedding_new_with_base(
2204    api_key: *const c_char,
2205    model_id: *const c_char,
2206    base_url: *const c_char,
2207    out_handle: *mut u64,
2208) -> *mut aimux_error_t {
2209    with_out_handle(out_handle, || {
2210        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2211        let mut config = CohereConfig::new(api_key);
2212        if let Some(url) = parse_base_url(base_url)? {
2213            config = config.with_base_url(url);
2214        }
2215        let model = CohereProvider::new(config).embedding_model(&model_id);
2216        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2217    })
2218}
2219
2220#[unsafe(no_mangle)]
2221pub extern "C" fn aimux_google_embedding_new(
2222    api_key: *const c_char,
2223    model_id: *const c_char,
2224    out_handle: *mut u64,
2225) -> *mut aimux_error_t {
2226    with_out_handle(out_handle, || {
2227        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2228        let model = GoogleProvider::new(GoogleConfig::new(api_key)).embedding_model(&model_id);
2229        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2230    })
2231}
2232
2233#[unsafe(no_mangle)]
2234pub extern "C" fn aimux_google_embedding_new_with_base(
2235    api_key: *const c_char,
2236    model_id: *const c_char,
2237    base_url: *const c_char,
2238    out_handle: *mut u64,
2239) -> *mut aimux_error_t {
2240    with_out_handle(out_handle, || {
2241        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2242        let mut config = GoogleConfig::new(api_key);
2243        if let Some(url) = parse_base_url(base_url)? {
2244            config = config.with_base_url(url);
2245        }
2246        let model = GoogleProvider::new(config).embedding_model(&model_id);
2247        Ok(intern_handle(HandleEntry::Embedding(Arc::new(model))))
2248    })
2249}
2250
2251/// Generate embeddings. `values_json` is a JSON array of strings.
2252/// Writes `EmbeddingResult` JSON (caller frees).
2253#[unsafe(no_mangle)]
2254pub extern "C" fn aimux_embed(
2255    handle: u64,
2256    values_json: *const c_char,
2257    opts_json: *const c_char,
2258    out_json: *mut *mut c_char,
2259) -> *mut aimux_error_t {
2260    with_out_string(out_json, "out_json", || {
2261        let HandleEntry::Embedding(model) = entry_of(handle, "embedding")? else {
2262            return Err(FfiError::InvalidHandle {
2263                expected: "embedding",
2264            }
2265            .into());
2266        };
2267        let values_json = str_arg(values_json, "values_json")?;
2268        let mut opts = aimux_core::embedding_model::EmbeddingCallOptions::new("");
2269        if let Some(s) = opt_str_arg(opts_json, "opts_json")?
2270            && !s.trim().is_empty()
2271            && s.trim() != "null"
2272        {
2273            opts = serde_json::from_str(&s).map_err(|e| wire_err("opts_json", e))?;
2274        }
2275        opts.values = serde_json::from_str(&values_json).map_err(|e| wire_err("values_json", e))?;
2276        run_json(async move { aimux_core::embedding_model::embed(model.as_ref(), opts).await })
2277    })
2278}
2279
2280// ─────────────────────────────────────────────────────────────────────────────
2281// C ABI: Speech (TTS)
2282// ─────────────────────────────────────────────────────────────────────────────
2283
2284#[unsafe(no_mangle)]
2285pub extern "C" fn aimux_openai_speech_new(
2286    api_key: *const c_char,
2287    model_id: *const c_char,
2288    out_handle: *mut u64,
2289) -> *mut aimux_error_t {
2290    with_out_handle(out_handle, || {
2291        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2292        let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).speech(&model_id);
2293        Ok(intern_handle(HandleEntry::Speech(Arc::new(model))))
2294    })
2295}
2296
2297#[unsafe(no_mangle)]
2298pub extern "C" fn aimux_openai_speech_new_with_base(
2299    api_key: *const c_char,
2300    model_id: *const c_char,
2301    base_url: *const c_char,
2302    out_handle: *mut u64,
2303) -> *mut aimux_error_t {
2304    with_out_handle(out_handle, || {
2305        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2306        let mut config = OpenAIConfig::new(api_key);
2307        if let Some(url) = parse_base_url(base_url)? {
2308            config = config.with_base_url(url);
2309        }
2310        let model = OpenAIProvider::new(config).speech(&model_id);
2311        Ok(intern_handle(HandleEntry::Speech(Arc::new(model))))
2312    })
2313}
2314
2315#[unsafe(no_mangle)]
2316pub extern "C" fn aimux_speech_generate(
2317    handle: u64,
2318    opts_json: *const c_char,
2319    out_json: *mut *mut c_char,
2320) -> *mut aimux_error_t {
2321    with_out_string(out_json, "out_json", || {
2322        let HandleEntry::Speech(model) = entry_of(handle, "speech")? else {
2323            return Err(FfiError::InvalidHandle { expected: "speech" }.into());
2324        };
2325        let opts: aimux_core::speech_model::SpeechCallOptions =
2326            parse_json_arg(opts_json, "opts_json")?;
2327        run_json(
2328            async move { aimux_core::speech_model::generate_speech(model.as_ref(), opts).await },
2329        )
2330    })
2331}
2332
2333// ─────────────────────────────────────────────────────────────────────────────
2334// C ABI: Image
2335// ─────────────────────────────────────────────────────────────────────────────
2336
2337#[unsafe(no_mangle)]
2338pub extern "C" fn aimux_openai_image_new(
2339    api_key: *const c_char,
2340    model_id: *const c_char,
2341    out_handle: *mut u64,
2342) -> *mut aimux_error_t {
2343    with_out_handle(out_handle, || {
2344        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2345        let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).image(&model_id);
2346        Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
2347    })
2348}
2349
2350#[unsafe(no_mangle)]
2351pub extern "C" fn aimux_openai_image_new_with_base(
2352    api_key: *const c_char,
2353    model_id: *const c_char,
2354    base_url: *const c_char,
2355    out_handle: *mut u64,
2356) -> *mut aimux_error_t {
2357    with_out_handle(out_handle, || {
2358        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2359        let mut config = OpenAIConfig::new(api_key);
2360        if let Some(url) = parse_base_url(base_url)? {
2361            config = config.with_base_url(url);
2362        }
2363        let model = OpenAIProvider::new(config).image(&model_id);
2364        Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
2365    })
2366}
2367
2368#[unsafe(no_mangle)]
2369pub extern "C" fn aimux_google_image_new(
2370    api_key: *const c_char,
2371    model_id: *const c_char,
2372    out_handle: *mut u64,
2373) -> *mut aimux_error_t {
2374    with_out_handle(out_handle, || {
2375        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2376        let model = GoogleProvider::new(GoogleConfig::new(api_key)).image(&model_id);
2377        Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
2378    })
2379}
2380
2381#[unsafe(no_mangle)]
2382pub extern "C" fn aimux_google_image_new_with_base(
2383    api_key: *const c_char,
2384    model_id: *const c_char,
2385    base_url: *const c_char,
2386    out_handle: *mut u64,
2387) -> *mut aimux_error_t {
2388    with_out_handle(out_handle, || {
2389        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2390        let mut config = GoogleConfig::new(api_key);
2391        if let Some(url) = parse_base_url(base_url)? {
2392            config = config.with_base_url(url);
2393        }
2394        let model = GoogleProvider::new(config).image(&model_id);
2395        Ok(intern_handle(HandleEntry::Image(Arc::new(model))))
2396    })
2397}
2398
2399#[unsafe(no_mangle)]
2400pub extern "C" fn aimux_image_generate(
2401    handle: u64,
2402    opts_json: *const c_char,
2403    out_json: *mut *mut c_char,
2404) -> *mut aimux_error_t {
2405    with_out_string(out_json, "out_json", || {
2406        let HandleEntry::Image(model) = entry_of(handle, "image")? else {
2407            return Err(FfiError::InvalidHandle { expected: "image" }.into());
2408        };
2409        let opts: aimux_core::image_model::ImageCallOptions =
2410            parse_json_arg(opts_json, "opts_json")?;
2411        run_json(async move { aimux_core::image_model::generate_image(model.as_ref(), opts).await })
2412    })
2413}
2414
2415// ─────────────────────────────────────────────────────────────────────────────
2416// C ABI: Transcription (non-streaming)
2417// ─────────────────────────────────────────────────────────────────────────────
2418
2419#[unsafe(no_mangle)]
2420pub extern "C" fn aimux_openai_transcription_new(
2421    api_key: *const c_char,
2422    model_id: *const c_char,
2423    out_handle: *mut u64,
2424) -> *mut aimux_error_t {
2425    with_out_handle(out_handle, || {
2426        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2427        let model = OpenAIProvider::new(OpenAIConfig::new(api_key)).transcription(&model_id);
2428        Ok(intern_handle(HandleEntry::Transcription(Arc::new(model))))
2429    })
2430}
2431
2432#[unsafe(no_mangle)]
2433pub extern "C" fn aimux_openai_transcription_new_with_base(
2434    api_key: *const c_char,
2435    model_id: *const c_char,
2436    base_url: *const c_char,
2437    out_handle: *mut u64,
2438) -> *mut aimux_error_t {
2439    with_out_handle(out_handle, || {
2440        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2441        let mut config = OpenAIConfig::new(api_key);
2442        if let Some(url) = parse_base_url(base_url)? {
2443            config = config.with_base_url(url);
2444        }
2445        let model = OpenAIProvider::new(config).transcription(&model_id);
2446        Ok(intern_handle(HandleEntry::Transcription(Arc::new(model))))
2447    })
2448}
2449
2450#[unsafe(no_mangle)]
2451pub extern "C" fn aimux_transcription_generate(
2452    handle: u64,
2453    audio_base64: *const c_char,
2454    media_type: *const c_char,
2455    _opts_json: *const c_char,
2456    out_json: *mut *mut c_char,
2457) -> *mut aimux_error_t {
2458    with_out_string(out_json, "out_json", || {
2459        let HandleEntry::Transcription(model) = entry_of(handle, "transcription")? else {
2460            return Err(FfiError::InvalidHandle {
2461                expected: "transcription",
2462            }
2463            .into());
2464        };
2465        let audio_base64 = str_arg(audio_base64, "audio_base64")?;
2466        let media_type = str_arg(media_type, "media_type")?;
2467        let opts = aimux_core::transcription_model::TranscriptionCallOptions::new(
2468            aimux_core::transcription_model::AudioInput::Base64(audio_base64),
2469            media_type,
2470        );
2471        run_json(
2472            async move { aimux_core::transcription_model::transcribe(model.as_ref(), opts).await },
2473        )
2474    })
2475}
2476
2477// ─────────────────────────────────────────────────────────────────────────────
2478// C ABI: transcription streaming sessions (RFC-0028 Phase 2)
2479// ─────────────────────────────────────────────────────────────────────────────
2480
2481/// JSON options for `aimux_transcription_session_new` (all fields optional):
2482/// `{ "input_audio_format": { "format_type": "audio/pcm", "rate": 24000 },
2483///    "provider_options": { … }, "headers": { … }, "include_raw_chunks": false }`.
2484#[derive(serde::Deserialize, Default)]
2485struct TranscriptionSessionFfiOptions {
2486    input_audio_format: Option<aimux_core::transcription_model::InputAudioFormat>,
2487    provider_options: Option<HashMap<String, serde_json::Value>>,
2488    headers: Option<HashMap<String, String>>,
2489    include_raw_chunks: Option<bool>,
2490    timeout: Option<aimux_core::options::TimeoutConfiguration>,
2491}
2492
2493/// Look up a transcription streaming session handle.
2494fn session_of(
2495    handle: u64,
2496) -> Result<Arc<transcription_session::TranscriptionFfiSession>, FfiError> {
2497    match entry_of(handle, "transcription session")? {
2498        HandleEntry::TranscriptionSession(s) => Ok(s),
2499        _ => Err(FfiError::InvalidHandle {
2500            expected: "transcription session",
2501        }),
2502    }
2503}
2504
2505/// Start a transcription streaming session (RFC-0028 Phase 2). Internally
2506/// spawns the driver task immediately; audio is then pushed with
2507/// `aimux_transcription_push_audio` and parts pulled with
2508/// `aimux_transcription_next_part`.
2509///
2510/// `model_handle` must be a transcription model handle that supports
2511/// streaming (e.g. OpenAI `gpt-realtime-whisper`); models without `do_stream`
2512/// fail on the first `next_part` (the connect/establishment error surfaces
2513/// there). `abort_handle` may be 0 (no cancellation) or an
2514/// `aimux_abort_signal_new` handle — firing it aborts the session.
2515#[unsafe(no_mangle)]
2516pub extern "C" fn aimux_transcription_session_new(
2517    model_handle: u64,
2518    abort_handle: u64,
2519    opts_json: *const c_char,
2520    out_handle: *mut u64,
2521) -> *mut aimux_error_t {
2522    with_out_handle(out_handle, || {
2523        let HandleEntry::Transcription(model) = entry_of(model_handle, "transcription")? else {
2524            return Err(FfiError::InvalidHandle {
2525                expected: "transcription",
2526            }
2527            .into());
2528        };
2529        let abort = if abort_handle == 0 {
2530            None
2531        } else {
2532            Some(abort_of(abort_handle)?)
2533        };
2534        // NULL / empty / "null" = defaults (shared convention).
2535        let opts_json = normalize_config_json(opts_json, "opts_json")?;
2536        let ffi_opts: TranscriptionSessionFfiOptions =
2537            serde_json::from_str(&opts_json).map_err(|e| wire_err("opts_json", e))?;
2538        let session = transcription_session::TranscriptionFfiSession::spawn(
2539            model,
2540            transcription_session::SessionOptions {
2541                input_audio_format: ffi_opts.input_audio_format,
2542                provider_options: ffi_opts.provider_options,
2543                headers: ffi_opts.headers,
2544                include_raw_chunks: ffi_opts.include_raw_chunks.unwrap_or(false),
2545                timeout: ffi_opts.timeout,
2546            },
2547            abort,
2548        );
2549        Ok(intern_handle(HandleEntry::TranscriptionSession(session)))
2550    })
2551}
2552
2553/// Push one binary audio chunk into the session. **Blocking**: waits while
2554/// the internal channel is full (backpressure propagation; the caller's
2555/// capture loop throttles). Must not be called from within an aimux callback
2556/// (re-entrancy guard rejects it).
2557///
2558/// `data` may be NULL only when `len == 0` (no-op). Failures: session ended /
2559/// input already finished → AiMuxError; NULL data with `len > 0` →
2560/// `NullPointer`; dead session → `InvalidHandle`.
2561#[unsafe(no_mangle)]
2562pub extern "C" fn aimux_transcription_push_audio(
2563    session: u64,
2564    data: *const u8,
2565    len: usize,
2566) -> *mut aimux_error_t {
2567    no_result(|| {
2568        let session = session_of(session)?;
2569        if data.is_null() {
2570            if len == 0 {
2571                return Ok(()); // no-op
2572            }
2573            return Err(FfiError::NullPointer { argument: "data" }.into());
2574        }
2575        // SAFETY: caller guarantees `data` points to `len` valid bytes; the copy
2576        // happens synchronously before any await.
2577        let bytes = unsafe { std::slice::from_raw_parts(data, len) }.to_vec();
2578        ffi_block_on(session.push_audio(bytes))??;
2579        Ok(())
2580    })
2581}
2582
2583/// Signal end-of-audio (the audio stream ends; the provider flushes). Safe to
2584/// call multiple times. Fails only for a dead session handle.
2585#[unsafe(no_mangle)]
2586pub extern "C" fn aimux_transcription_input_done(session: u64) -> *mut aimux_error_t {
2587    no_result(|| {
2588        session_of(session)?.input_done();
2589        Ok(())
2590    })
2591}
2592
2593/// Pull the next transcription part (JSON-serialized `TranscriptionStreamPart`;
2594/// free with `aimux_free_string`).
2595///
2596/// `timeout_ms`: >0 = wait at most that long; 0 = immediate poll; <0 = wait
2597/// indefinitely. Both out-params are required. On NULL return `*out_state`
2598/// says what happened:
2599/// - `AIMUX_TRANSCRIPTION_NEXT_PART_PART` — `*out_part` holds a part;
2600/// - `AIMUX_TRANSCRIPTION_NEXT_PART_ENDED` — the stream ended normally (a
2601///   `Finish` part was delivered earlier); `*out_part` is NULL;
2602/// - `AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT` — no part in time; the session
2603///   is still live, call again; `*out_part` is NULL.
2604///
2605/// A non-NULL return is a failure (abort / API error / dead handle);
2606/// `*out_part` is NULL and `*out_state` is unspecified.
2607#[unsafe(no_mangle)]
2608pub extern "C" fn aimux_transcription_next_part(
2609    session: u64,
2610    timeout_ms: i64,
2611    out_part: *mut *mut c_char,
2612    out_state: *mut i32,
2613) -> *mut aimux_error_t {
2614    if out_part.is_null() {
2615        return no_result(|| {
2616            Err(FfiError::NullPointer {
2617                argument: "out_part",
2618            }
2619            .into())
2620        });
2621    }
2622    // Sentinel first, so a failure below (including a NULL out_state) never
2623    // leaves a stale pointer in *out_part.
2624    unsafe { *out_part = std::ptr::null_mut() };
2625    if out_state.is_null() {
2626        return no_result(|| {
2627            Err(FfiError::NullPointer {
2628                argument: "out_state",
2629            }
2630            .into())
2631        });
2632    }
2633    let r: FfiResult<(i32, Option<String>)> = (|| {
2634        let session = session_of(session)?;
2635        let timeout = if timeout_ms < 0 {
2636            None
2637        } else {
2638            Some(std::time::Duration::from_millis(timeout_ms as u64))
2639        };
2640        Ok(match ffi_block_on(session.next_part(timeout))? {
2641            // A part arrived.
2642            Ok(Some(Ok(part))) => (AIMUX_TRANSCRIPTION_NEXT_PART_PART, Some(to_json(&part)?)),
2643            // Channel closed: normal end.
2644            Ok(None) => (AIMUX_TRANSCRIPTION_NEXT_PART_ENDED, None),
2645            // No part within `timeout`; the session is still live.
2646            Err(AiMuxError::Timeout(_)) => (AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None),
2647            // The part stream itself errored (abort / API error) — final error.
2648            Ok(Some(Err(e))) | Err(e) => return Err(e.into()),
2649        })
2650    })();
2651    finish(r, |(state, part)| unsafe {
2652        *out_state = state;
2653        if let Some(p) = part {
2654            *out_part = into_cstring_raw(p);
2655        }
2656    })
2657}
2658
2659/// Terminate a transcription session and release it: aborts the driver,
2660/// joins (bounded), and drops the handle. Safe with 0 or an unknown handle.
2661#[unsafe(no_mangle)]
2662pub extern "C" fn aimux_transcription_session_drop(session: u64) {
2663    // Remove from the registry FIRST (the join below must not hold the
2664    // registry mutex).
2665    let removed = registry()
2666        .lock()
2667        .expect("aimux-ffi: registry mutex poisoned")
2668        .remove(&session);
2669    if let Some(HandleEntry::TranscriptionSession(s)) = removed {
2670        s.terminate();
2671    }
2672}
2673
2674// ─────────────────────────────────────────────────────────────────────────────
2675// C ABI: Files
2676// ─────────────────────────────────────────────────────────────────────────────
2677
2678#[unsafe(no_mangle)]
2679pub extern "C" fn aimux_openai_files_new(
2680    api_key: *const c_char,
2681    out_handle: *mut u64,
2682) -> *mut aimux_error_t {
2683    with_out_handle(out_handle, || {
2684        let api_key = str_arg(api_key, "api_key")?;
2685        let files = OpenAIProvider::new(OpenAIConfig::new(api_key)).files();
2686        Ok(intern_handle(HandleEntry::Files(Arc::new(files))))
2687    })
2688}
2689
2690#[unsafe(no_mangle)]
2691pub extern "C" fn aimux_openai_files_new_with_base(
2692    api_key: *const c_char,
2693    base_url: *const c_char,
2694    out_handle: *mut u64,
2695) -> *mut aimux_error_t {
2696    with_out_handle(out_handle, || {
2697        let api_key = str_arg(api_key, "api_key")?;
2698        let mut config = OpenAIConfig::new(api_key);
2699        if let Some(url) = parse_base_url(base_url)? {
2700            config = config.with_base_url(url);
2701        }
2702        let files = OpenAIProvider::new(config).files();
2703        Ok(intern_handle(HandleEntry::Files(Arc::new(files))))
2704    })
2705}
2706
2707#[unsafe(no_mangle)]
2708pub extern "C" fn aimux_file_upload(
2709    handle: u64,
2710    data_base64: *const c_char,
2711    media_type: *const c_char,
2712    _opts_json: *const c_char,
2713    out_json: *mut *mut c_char,
2714) -> *mut aimux_error_t {
2715    with_out_string(out_json, "out_json", || {
2716        let HandleEntry::Files(model) = entry_of(handle, "files")? else {
2717            return Err(FfiError::InvalidHandle { expected: "files" }.into());
2718        };
2719        let data_base64 = str_arg(data_base64, "data_base64")?;
2720        let media_type = str_arg(media_type, "media_type")?;
2721        let opts = aimux_core::files_model::UploadFileCallOptions::new(
2722            aimux_core::files_model::UploadFileData::Data {
2723                data: aimux_core::shared::FileBytes::Base64(data_base64),
2724            },
2725            media_type,
2726        );
2727        run_json(async move { model.upload_file(&opts).await })
2728    })
2729}
2730
2731// ─────────────────────────────────────────────────────────────────────────────
2732// C ABI: Reranking
2733// ─────────────────────────────────────────────────────────────────────────────
2734
2735/// Create a Cohere reranking model instance.
2736#[unsafe(no_mangle)]
2737pub extern "C" fn aimux_cohere_reranking_new(
2738    api_key: *const c_char,
2739    model_id: *const c_char,
2740    out_handle: *mut u64,
2741) -> *mut aimux_error_t {
2742    with_out_handle(out_handle, || {
2743        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2744        let model = CohereProvider::new(CohereConfig::new(api_key)).reranking_model(&model_id);
2745        Ok(intern_handle(HandleEntry::Reranking(Arc::new(model))))
2746    })
2747}
2748
2749#[unsafe(no_mangle)]
2750pub extern "C" fn aimux_cohere_reranking_new_with_base(
2751    api_key: *const c_char,
2752    model_id: *const c_char,
2753    base_url: *const c_char,
2754    out_handle: *mut u64,
2755) -> *mut aimux_error_t {
2756    with_out_handle(out_handle, || {
2757        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2758        let mut config = CohereConfig::new(api_key);
2759        if let Some(url) = parse_base_url(base_url)? {
2760            config = config.with_base_url(url);
2761        }
2762        let model = CohereProvider::new(config).reranking_model(&model_id);
2763        Ok(intern_handle(HandleEntry::Reranking(Arc::new(model))))
2764    })
2765}
2766
2767/// Rerank documents. `opts_json` is JSON-serialized `RerankingCallOptions`
2768/// (must contain `query` and `documents`). Writes `RerankingResult` JSON.
2769#[unsafe(no_mangle)]
2770pub extern "C" fn aimux_rerank(
2771    handle: u64,
2772    opts_json: *const c_char,
2773    out_json: *mut *mut c_char,
2774) -> *mut aimux_error_t {
2775    with_out_string(out_json, "out_json", || {
2776        let HandleEntry::Reranking(model) = entry_of(handle, "reranking")? else {
2777            return Err(FfiError::InvalidHandle {
2778                expected: "reranking",
2779            }
2780            .into());
2781        };
2782        let opts: aimux_core::reranking_model::RerankingCallOptions =
2783            parse_json_arg(opts_json, "opts_json")?;
2784        run_json(async move { aimux_core::reranking_model::rerank(model.as_ref(), opts).await })
2785    })
2786}
2787
2788// ─────────────────────────────────────────────────────────────────────────────
2789// C ABI: Video
2790// ─────────────────────────────────────────────────────────────────────────────
2791
2792/// Create a Google video model instance.
2793#[unsafe(no_mangle)]
2794pub extern "C" fn aimux_google_video_new(
2795    api_key: *const c_char,
2796    model_id: *const c_char,
2797    out_handle: *mut u64,
2798) -> *mut aimux_error_t {
2799    with_out_handle(out_handle, || {
2800        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2801        let model = GoogleProvider::new(GoogleConfig::new(api_key)).video(&model_id);
2802        Ok(intern_handle(HandleEntry::Video(Arc::new(model))))
2803    })
2804}
2805
2806#[unsafe(no_mangle)]
2807pub extern "C" fn aimux_google_video_new_with_base(
2808    api_key: *const c_char,
2809    model_id: *const c_char,
2810    base_url: *const c_char,
2811    out_handle: *mut u64,
2812) -> *mut aimux_error_t {
2813    with_out_handle(out_handle, || {
2814        let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
2815        let mut config = GoogleConfig::new(api_key);
2816        if let Some(url) = parse_base_url(base_url)? {
2817            config = config.with_base_url(url);
2818        }
2819        let model = GoogleProvider::new(config).video(&model_id);
2820        Ok(intern_handle(HandleEntry::Video(Arc::new(model))))
2821    })
2822}
2823
2824/// Generate video. `opts_json` is JSON-serialized `VideoCallOptions`
2825/// (must contain `prompt`). Writes `VideoResult` JSON.
2826#[unsafe(no_mangle)]
2827pub extern "C" fn aimux_video_generate(
2828    handle: u64,
2829    opts_json: *const c_char,
2830    out_json: *mut *mut c_char,
2831) -> *mut aimux_error_t {
2832    with_out_string(out_json, "out_json", || {
2833        let HandleEntry::Video(model) = entry_of(handle, "video")? else {
2834            return Err(FfiError::InvalidHandle { expected: "video" }.into());
2835        };
2836        let opts: aimux_core::video_model::VideoCallOptions =
2837            parse_json_arg(opts_json, "opts_json")?;
2838        run_json(async move { aimux_core::video_model::generate_video(model.as_ref(), opts).await })
2839    })
2840}
2841
2842// ─────────────────────────────────────────────────────────────────────────────
2843// C ABI: Search
2844// ─────────────────────────────────────────────────────────────────────────────
2845
2846/// Create a Tavily search model instance. `model_id` is accepted for API
2847/// symmetry but ignored (Tavily uses a fixed endpoint).
2848#[unsafe(no_mangle)]
2849pub extern "C" fn aimux_tavily_search_new(
2850    api_key: *const c_char,
2851    _model_id: *const c_char,
2852    out_handle: *mut u64,
2853) -> *mut aimux_error_t {
2854    with_out_handle(out_handle, || {
2855        let api_key = str_arg(api_key, "api_key")?;
2856        let model = TavilyProvider::new(TavilyConfig::new(api_key)).search_model();
2857        Ok(intern_handle(HandleEntry::Search(Arc::new(model))))
2858    })
2859}
2860
2861#[unsafe(no_mangle)]
2862pub extern "C" fn aimux_tavily_search_new_with_base(
2863    api_key: *const c_char,
2864    _model_id: *const c_char,
2865    base_url: *const c_char,
2866    out_handle: *mut u64,
2867) -> *mut aimux_error_t {
2868    with_out_handle(out_handle, || {
2869        let api_key = str_arg(api_key, "api_key")?;
2870        let mut config = TavilyConfig::new(api_key);
2871        if let Some(url) = parse_base_url(base_url)? {
2872            config = config.with_base_url(url);
2873        }
2874        let model = TavilyProvider::new(config).search_model();
2875        Ok(intern_handle(HandleEntry::Search(Arc::new(model))))
2876    })
2877}
2878
2879/// Execute a search. `opts_json` is JSON-serialized `SearchCallOptions`
2880/// (must contain `query`). Writes `SearchResult` JSON.
2881#[unsafe(no_mangle)]
2882pub extern "C" fn aimux_search(
2883    handle: u64,
2884    opts_json: *const c_char,
2885    out_json: *mut *mut c_char,
2886) -> *mut aimux_error_t {
2887    with_out_string(out_json, "out_json", || {
2888        let HandleEntry::Search(model) = entry_of(handle, "search")? else {
2889            return Err(FfiError::InvalidHandle { expected: "search" }.into());
2890        };
2891        let opts: aimux_core::search_model::SearchCallOptions =
2892            parse_json_arg(opts_json, "opts_json")?;
2893        run_json(async move { aimux_core::search_model::search(model.as_ref(), opts).await })
2894    })
2895}
2896
2897// ─────────────────────────────────────────────────────────────────────────────
2898// C ABI: Codex subscription helper (RFC-0018 §3.2)
2899// ─────────────────────────────────────────────────────────────────────────────
2900
2901/// Refresh a Codex subscription access token (RFC-0018 §3.2).
2902///
2903/// Stateless: performs one OAuth `refresh_token` grant against
2904/// `auth.openai.com/oauth/token`. Writes
2905/// `{"access_token","refresh_token","expires_in_secs"}` JSON on success
2906/// (caller frees with `aimux_free_string`). The caller owns token persistence
2907/// and the 401 → refresh → retry orchestration — the library never stores
2908/// credentials.
2909#[unsafe(no_mangle)]
2910pub extern "C" fn aimux_codex_refresh(
2911    refresh_token: *const c_char,
2912    client_id: *const c_char,
2913    out_json: *mut *mut c_char,
2914) -> *mut aimux_error_t {
2915    with_out_string(out_json, "out_json", || {
2916        let (refresh_token, client_id) =
2917            parse_two_args(refresh_token, "refresh_token", client_id, "client_id")?;
2918        run_json(async move { aimux_providers::codex_refresh(&refresh_token, &client_id).await })
2919    })
2920}
2921
2922// ─────────────────────────────────────────────────────────────────────────────
2923// C ABI: Logging (RFC-0014)
2924// ─────────────────────────────────────────────────────────────────────────────
2925
2926/// Initialize the global logger. Idempotent — safe to call any number of
2927/// times from any thread; only the first call has an effect.
2928///
2929/// If the host already registered its own `tracing` subscriber, this is a
2930/// no-op (aimux never overrides a consumer's logger).
2931///
2932/// @param level NUL-terminated level string: "off" / "error" / "warn" /
2933///              "info" / "debug" / "trace". NULL falls back to the default
2934///              ("warn"); a non-UTF-8 pointer is `InvalidUtf8`. `AIMUX_LOG`
2935///              (RUST_LOG-style) and `AIMUX_LOG_LEVEL` env vars take
2936///              precedence when set. Logs go to stderr.
2937#[unsafe(no_mangle)]
2938pub extern "C" fn aimux_init_logging(level: *const c_char) -> *mut aimux_error_t {
2939    no_result(|| {
2940        let level = opt_str_arg(level, "level")?;
2941        aimux_providers::init_logging(level.as_deref().unwrap_or("warn"));
2942        Ok(())
2943    })
2944}
2945
2946// ─────────────────────────────────────────────────────────────────────────────
2947// C ABI: session grouping (RFC-0024)
2948// ─────────────────────────────────────────────────────────────────────────────
2949
2950/// Register the global session store (RFC-0024). Replaces any previous one.
2951/// Until called, calls are not grouped and the session query functions return
2952/// empty results.
2953#[unsafe(no_mangle)]
2954pub extern "C" fn aimux_session_store_init() {
2955    aimux_core::session::init_session_store(std::sync::Arc::new(
2956        aimux_core::session::SessionStore::new(),
2957    ));
2958}
2959
2960/// Enable/disable the global session inferer (RFC-0024, opt-in, off by
2961/// default). Explicit `session_id` values always win regardless.
2962/// `enabled` nonzero = on.
2963#[unsafe(no_mangle)]
2964pub extern "C" fn aimux_session_infer_init(enabled: i32) {
2965    aimux_core::session::init_session_infer(enabled != 0);
2966}
2967
2968/// Query: all calls of a session, ordered by step (RFC-0024).
2969///
2970/// Writes a serialized `SessionCall[]` (empty array if the session is unknown
2971/// or no store is registered); the caller frees it with [`aimux_free_string`].
2972#[unsafe(no_mangle)]
2973pub extern "C" fn aimux_session_calls(
2974    session_id: *const c_char,
2975    out_json: *mut *mut c_char,
2976) -> *mut aimux_error_t {
2977    with_out_string(out_json, "out_json", || {
2978        let id = str_arg(session_id, "session_id")?;
2979        to_json(&aimux_core::session::session_calls(&id))
2980    })
2981}
2982
2983/// Query: all known sessions (RFC-0024). Writes a serialized `SessionView[]`.
2984#[unsafe(no_mangle)]
2985pub extern "C" fn aimux_list_sessions(out_json: *mut *mut c_char) -> *mut aimux_error_t {
2986    with_out_string(out_json, "out_json", || {
2987        to_json(&aimux_core::session::list_sessions())
2988    })
2989}
2990
2991// ─────────────────────────────────────────────────────────────────────────────
2992// C ABI: cache probing (RFC-0015)
2993// ─────────────────────────────────────────────────────────────────────────────
2994
2995static TRACE_STORES: OnceLock<Mutex<HashMap<u64, Arc<RingTraceStore>>>> = OnceLock::new();
2996
2997fn trace_stores() -> &'static Mutex<HashMap<u64, Arc<RingTraceStore>>> {
2998    TRACE_STORES.get_or_init(|| Mutex::new(HashMap::new()))
2999}
3000
3001fn trace_store_of(handle: u64) -> Result<Arc<RingTraceStore>, FfiError> {
3002    trace_stores()
3003        .lock()
3004        .expect("aimux-ffi: trace registry mutex poisoned")
3005        .get(&handle)
3006        .cloned()
3007        .ok_or(FfiError::InvalidHandle { expected: "trace" })
3008}
3009
3010fn trace_wrap(handle: u64, audited: Option<bool>) -> FfiResult<u64> {
3011    let model = model_of(handle)?;
3012    let store = Arc::new(RingTraceStore::new());
3013    let mut layer = TraceLayer::new(model, store.clone());
3014    if let Some(strict) = audited {
3015        layer = layer.with_rules_auditor(strict);
3016    }
3017    let new_handle = intern_model(Arc::new(layer));
3018    trace_stores()
3019        .lock()
3020        .expect("aimux-ffi: trace registry mutex poisoned")
3021        .insert(new_handle, store);
3022    Ok(new_handle)
3023}
3024
3025/// Wrap a model handle in a probe layer (RFC-0015). The new handle can be
3026/// used with `aimux_generate_text` / `aimux_stream_text` (probed) and with
3027/// the `aimux_trace_*` query functions; release with `aimux_drop_handle`.
3028#[unsafe(no_mangle)]
3029pub extern "C" fn aimux_trace_new(handle: u64, out_handle: *mut u64) -> *mut aimux_error_t {
3030    with_out_handle(out_handle, || trace_wrap(handle, None))
3031}
3032
3033/// Wrap a model handle in a probe layer WITH the built-in rules auditor
3034/// (RFC-0015 §4). `strict` nonzero = strict mode (self-hosted single
3035/// instance); zero = shared mode (safe default). Release with
3036/// `aimux_drop_handle`.
3037#[unsafe(no_mangle)]
3038pub extern "C" fn aimux_trace_new_audited(
3039    handle: u64,
3040    strict: i32,
3041    out_handle: *mut u64,
3042) -> *mut aimux_error_t {
3043    with_out_handle(out_handle, || trace_wrap(handle, Some(strict != 0)))
3044}
3045
3046/// Query: aggregated probe statistics, filtered by `filter_json` (a serialized
3047/// `TraceFilter`; pass `"{}"` for all rows — NULL is `NullPointer`). Writes
3048/// JSON `TraceStats[]`; caller frees with `aimux_free_string`.
3049#[unsafe(no_mangle)]
3050pub extern "C" fn aimux_trace_aggregate(
3051    handle: u64,
3052    filter_json: *const c_char,
3053    out_json: *mut *mut c_char,
3054) -> *mut aimux_error_t {
3055    with_out_string(out_json, "out_json", || {
3056        let store = trace_store_of(handle)?;
3057        let filter = parse_json_arg::<TraceFilter>(filter_json, "filter_json")?;
3058        to_json(&store.aggregate(&filter))
3059    })
3060}
3061
3062/// Query: one session's chain view. Writes JSON `SessionChainView`; caller
3063/// frees with `aimux_free_string`. An unknown `session_id` is a lookup miss
3064/// on a caller-supplied value and is reported as
3065/// `AiMuxError::InvalidArgument` ("unknown session"), not as an invalid C
3066/// handle — the id is a string key, not a registry handle.
3067#[unsafe(no_mangle)]
3068pub extern "C" fn aimux_trace_session_chain(
3069    handle: u64,
3070    session_id: *const c_char,
3071    out_json: *mut *mut c_char,
3072) -> *mut aimux_error_t {
3073    with_out_string(out_json, "out_json", || {
3074        let store = trace_store_of(handle)?;
3075        let id = str_arg(session_id, "session_id")?;
3076        let view = store
3077            .session_chain(&id)
3078            .ok_or_else(|| AiMuxError::InvalidArgument("unknown session".into()))?;
3079        to_json(&view)
3080    })
3081}
3082
3083/// Query: one session's per-step cache-hit trajectory (RFC-0024 §4.3).
3084/// Writes a JSON array of `SessionStepStat` (empty for unknown sessions);
3085/// caller frees with `aimux_free_string`.
3086#[unsafe(no_mangle)]
3087pub extern "C" fn aimux_trace_session_trajectory(
3088    handle: u64,
3089    session_id: *const c_char,
3090    out_json: *mut *mut c_char,
3091) -> *mut aimux_error_t {
3092    with_out_string(out_json, "out_json", || {
3093        let store = trace_store_of(handle)?;
3094        let id = str_arg(session_id, "session_id")?;
3095        to_json(&store.session_cache_trajectory(&id))
3096    })
3097}
3098
3099/// Export all probe records as JSONL (one `TraceRecord` per line). Writes a
3100/// string with embedded newlines; caller frees with `aimux_free_string`.
3101#[unsafe(no_mangle)]
3102pub extern "C" fn aimux_trace_export_jsonl(
3103    handle: u64,
3104    out_jsonl: *mut *mut c_char,
3105) -> *mut aimux_error_t {
3106    with_out_string(out_jsonl, "out_jsonl", || {
3107        let store = trace_store_of(handle)?;
3108        let mut buf = Vec::new();
3109        store
3110            .export_jsonl(&mut buf)
3111            .map_err(|e| FfiError::ResultSerialization {
3112                message: format!("export: {e}"),
3113            })?;
3114        String::from_utf8(buf).map_err(|e| {
3115            FfiError::ResultSerialization {
3116                message: format!("utf8: {e}"),
3117            }
3118            .into()
3119        })
3120    })
3121}
3122
3123/// Clear all probe records of a trace handle. Fails only for a dead handle.
3124#[unsafe(no_mangle)]
3125pub extern "C" fn aimux_trace_clear(handle: u64) -> *mut aimux_error_t {
3126    no_result(|| {
3127        trace_store_of(handle)?.clear();
3128        Ok(())
3129    })
3130}
3131
3132// ─────────────────────────────────────────────────────────────────────────────
3133// C ABI: recording + mock replay (RFC-0023)
3134// ─────────────────────────────────────────────────────────────────────────────
3135
3136/// Start recording (RFC-0023 P1/P2): writes complete `Recording` JSONL to
3137/// `{dir}/recordings.jsonl` (dir auto-created). Recording is **opt-in**.
3138/// Calling again with a different dir replaces the recorder.
3139///
3140/// A null / non-UTF-8 `dir` is a C ABI failure; recorder construction
3141/// failures carry the recording view (`AIMUX_E_RECORDING_INIT`,
3142/// `AIMUX_E_RECORDING_OPEN_FILE`, or `AIMUX_E_RECORDING_SPAWN`). On failure
3143/// the previous recorder, if any, is left in place.
3144#[unsafe(no_mangle)]
3145pub extern "C" fn aimux_init_recording(dir: *const c_char) -> *mut aimux_error_t {
3146    no_result(|| {
3147        let dir = str_arg(dir, "dir")?;
3148        let rec = aimux_core::recording::JsonlRecorder::try_new(dir)?;
3149        aimux_core::recording::init_recording(Some(std::sync::Arc::new(rec)));
3150        Ok(())
3151    })
3152}
3153
3154/// Start in-memory bounded recording (RFC-0023 P6): `RingRecorder` with `cap`
3155/// entries, FIFO eviction, dropped-count queryable. `cap == 0` is
3156/// `AiMuxError::InvalidArgument`.
3157#[unsafe(no_mangle)]
3158pub extern "C" fn aimux_init_recording_ring(cap: u64) -> *mut aimux_error_t {
3159    no_result(|| {
3160        if cap == 0 {
3161            return Err(AiMuxError::InvalidArgument("cap: must be > 0".into()).into());
3162        }
3163        aimux_core::recording::init_recording(Some(std::sync::Arc::new(
3164            aimux_core::recording::RingRecorder::with_capacity(cap as usize),
3165        )));
3166        Ok(())
3167    })
3168}
3169
3170/// No-argument variant of [`aimux_init_recording_ring`]: initialize the global
3171/// recorder with a `RingRecorder` at the library default capacity (2048
3172/// entries, [`aimux_core::recording::RingRecorder::default`]). Ordinary callers
3173/// should prefer this entry point and leave the ring size to the library; pass
3174/// an explicit `cap` via [`aimux_init_recording_ring`] only when a different
3175/// size is required.
3176#[unsafe(no_mangle)]
3177pub extern "C" fn aimux_init_recording_ring_default() {
3178    aimux_core::recording::init_recording(Some(std::sync::Arc::new(
3179        aimux_core::recording::RingRecorder::default(),
3180    )));
3181}
3182
3183/// Stop recording: global recorder = None (new calls are unrecorded).
3184#[unsafe(no_mangle)]
3185pub extern "C" fn aimux_recording_stop() {
3186    aimux_core::recording::init_recording(None);
3187}
3188
3189/// Flush the global recorder (blocks until JSONL is on disk; no-op for the
3190/// ring recorder). Write failures are not reported here — see
3191/// [`aimux_recording_try_flush`].
3192#[unsafe(no_mangle)]
3193pub extern "C" fn aimux_recording_flush() {
3194    if let Some(rec) = aimux_core::recording::recorder() {
3195        rec.flush();
3196    }
3197}
3198
3199/// Flush the global recorder and **report write failures** (see #136).
3200///
3201/// Unlike [`aimux_recording_flush`], this surface makes the recorder's
3202/// durability observable across the C ABI: NULL when the data is confirmed
3203/// on disk (also when recording was never initialized: nothing to flush), an
3204/// returned error with the recording view otherwise. Codes reachable from a
3205/// flush:
3206/// - `AIMUX_E_RECORDING_WRITE` — a prior write failed (e.g. ENOSPC); the
3207///   first error is sticky and every later flush keeps reporting it.
3208/// - `AIMUX_E_RECORDING_WRITER_GONE` — the writer thread is unavailable.
3209/// - `AIMUX_E_RECORDING_FLUSH_TIMEOUT` — no writer ack within 30s.
3210#[unsafe(no_mangle)]
3211pub extern "C" fn aimux_recording_try_flush() -> *mut aimux_error_t {
3212    no_result(|| {
3213        if let Some(rec) = aimux_core::recording::recorder() {
3214            rec.try_flush()?;
3215        }
3216        Ok(())
3217    })
3218}
3219
3220/// Register external OpenAI-compatible providers from a JSON config string
3221/// (RFC-0020). `config_json` is `{ "providers": [ { "name": ..., "base_url":
3222/// ..., ... }, ... ] }`. Entries override same-named built-ins or add new
3223/// ones. Malformed JSON text is `InvalidWireJson`; a well-formed document the
3224/// registry rejects is `AiMuxError::InvalidArgument`.
3225#[unsafe(no_mangle)]
3226pub extern "C" fn aimux_register_providers(config_json: *const c_char) -> *mut aimux_error_t {
3227    no_result(|| {
3228        let json = str_arg(config_json, "config_json")?;
3229        // Malformed JSON text is this layer's finding; a well-formed document
3230        // that the registry rejects (bad schema, unknown protocol) is an AiMuxError.
3231        serde_json::from_str::<serde_json::Value>(&json).map_err(|e| wire_err("config_json", e))?;
3232        aimux_providers::load_providers_from_json(&json).map_err(|e| match e {
3233            // The registry reports a schema mismatch as `JsonParse`; the text
3234            // already parsed above, so what it rejected is the shape —
3235            // `AiMuxError::InvalidArgument`, not a provider-response parse failure.
3236            AiMuxError::JsonParse(m) => AiMuxError::InvalidArgument(format!("config_json: {m}")),
3237            e => e,
3238        })?;
3239        Ok(())
3240    })
3241}
3242
3243/// Set the global proxy configuration (M6, RFC-0016). Must be called before
3244/// the first `aimux_generate_text` / `aimux_stream_text` call; a no-op if the
3245/// shared HTTP client is already initialised.
3246///
3247/// `config_json` is a serialized `ProxyConfig`:
3248/// `{ "http_url": "...", "https_url": "...", "all_url": "...", "no_proxy":
3249/// "..." }` (all fields optional; omitting all is equivalent to relying on the
3250/// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` env vars).
3251#[unsafe(no_mangle)]
3252pub extern "C" fn aimux_init_proxy(config_json: *const c_char) -> *mut aimux_error_t {
3253    no_result(|| {
3254        let config: aimux_provider_utils::ProxyConfig = parse_json_arg(config_json, "config_json")?;
3255        // `init_proxy` returns false when the shared client is already up; treat
3256        // that as success (idempotent) so callers don't need to reason about
3257        // ordering races.
3258        let _ = aimux_provider_utils::init_proxy(config);
3259        Ok(())
3260    })
3261}
3262
3263/// Create a mock replay model from recorded JSONL (RFC-0023 P3). `recordings`
3264/// is one `Recording` JSON per line. The handle works with
3265/// `aimux_generate_text` / `aimux_stream_text` (no real API is sent).
3266#[unsafe(no_mangle)]
3267pub extern "C" fn aimux_mock_replay_new(
3268    recordings_jsonl: *const c_char,
3269    out_handle: *mut u64,
3270) -> *mut aimux_error_t {
3271    with_out_handle(out_handle, || {
3272        let recordings_jsonl = str_arg(recordings_jsonl, "recordings_jsonl")?;
3273        let mut recordings: Vec<aimux_core::recording::Recording> = Vec::new();
3274        for (idx, line) in recordings_jsonl.lines().enumerate() {
3275            let line = line.trim();
3276            if line.is_empty() {
3277                continue;
3278            }
3279            recordings.push(serde_json::from_str(line).map_err(|e| {
3280                wire_failure("recordings_jsonl", &e, format!("line {}: {e}", idx + 1))
3281            })?);
3282        }
3283        if recordings.is_empty() {
3284            return Err(
3285                AiMuxError::InvalidArgument("recordings_jsonl: no recordings".into()).into(),
3286            );
3287        }
3288        let model = aimux_core::replay::MockReplayModel::new(
3289            recordings[0].provider.provider.clone(),
3290            recordings[0].provider.model_id.clone(),
3291            recordings,
3292        );
3293        Ok(intern_model(Arc::new(model)))
3294    })
3295}
3296
3297/// Resolve an array of model handles for a composite (router / moa).
3298///
3299/// `NULL` with `len == 0` is an empty list; `NULL` with `len > 0` is
3300/// `NullPointer`; any element that is not a live model handle is
3301/// `InvalidHandle { expected }` — a dead child is a caller bug, never
3302/// silently dropped (the composite would otherwise run with fewer members
3303/// than the caller believes).
3304fn model_handles(
3305    handles: *const u64,
3306    len: usize,
3307    argument: &'static str,
3308    expected: &'static str,
3309) -> FfiResult<Vec<Arc<dyn aimux_core::LanguageModel>>> {
3310    let slice: &[u64] = if handles.is_null() {
3311        if len != 0 {
3312            return Err(FfiError::NullPointer { argument }.into());
3313        }
3314        &[]
3315    } else {
3316        // SAFETY: caller guarantees `handles` points to `len` u64s.
3317        unsafe { std::slice::from_raw_parts(handles, len) }
3318    };
3319    slice
3320        .iter()
3321        .map(|&h| get_model(h).ok_or_else(|| FfiError::InvalidHandle { expected }.into()))
3322        .collect()
3323}
3324
3325/// Create a `RouterModel` (RFC-0021) over the given child-model handles. The
3326/// new handle is itself a model handle (works with `aimux_generate_text` /
3327/// `aimux_stream_text`); free it with `aimux_drop_handle`.
3328///
3329/// `handles` is an array of `len` existing model handles (e.g. from
3330/// `aimux_openai_new`). `config_json` selects the router + fallback policy:
3331/// `{ "router": "rule" | "weighted", "weights": [..], "fallback": "on_error" |
3332/// "none", "provider_name": "router", "model_id": "router" }`. All keys are
3333/// optional; defaults are `rule` / `on_error` / `"router"` / `"router"`.
3334///
3335/// Fails on: NULL `handles` with `len > 0`, bad JSON, zero-length `handles`,
3336/// or any dead child handle (nothing is silently dropped).
3337#[unsafe(no_mangle)]
3338pub extern "C" fn aimux_router_new(
3339    handles: *const u64,
3340    len: usize,
3341    config_json: *const c_char,
3342    out_handle: *mut u64,
3343) -> *mut aimux_error_t {
3344    with_out_handle(out_handle, || {
3345        let models = model_handles(handles, len, "handles", "router child")?;
3346        if models.is_empty() {
3347            // No children at all: nothing to route to.
3348            return Err(FfiError::InvalidHandle {
3349                expected: "router child",
3350            }
3351            .into());
3352        }
3353        // NULL / empty / "null" all mean "defaults" (matching parse_provider_options
3354        // convention). Invalid UTF-8 → FFI `InvalidUtf8`.
3355        let config_json = normalize_config_json(config_json, "config_json")?;
3356        let cfg: RouterFfiConfig =
3357            serde_json::from_str(&config_json).map_err(|e| wire_err("config_json", e))?;
3358        let router: Box<dyn aimux_core::router::Router> = match cfg.router.as_deref() {
3359            Some("weighted") => {
3360                let weights = cfg.weights.unwrap_or_else(|| vec![1.0; models.len()]);
3361                Box::new(aimux_core::router::WeightedRouter::new(weights))
3362            }
3363            // "rule", None, and any unknown value fall back to RuleRouter (safest
3364            // default: child 0 + ordered fallback).
3365            _ => Box::new(aimux_core::router::RuleRouter),
3366        };
3367        let fallback = if cfg.fallback.as_deref() == Some("none") {
3368            aimux_core::router::FallbackPolicy::None
3369        } else {
3370            aimux_core::router::FallbackPolicy::OnError
3371        };
3372        let router_cfg = aimux_core::router::RouterConfig {
3373            provider_name: cfg.provider_name.unwrap_or_else(|| "router".into()),
3374            model_id: cfg.model_id.unwrap_or_else(|| "router".into()),
3375        };
3376        let model = aimux_core::router::RouterModel::new(models, router, fallback, router_cfg);
3377        Ok(intern_model(Arc::new(model)))
3378    })
3379}
3380
3381/// Create a `MoaModel` (RFC-0022) over the given reference handles + one
3382/// aggregator handle. The new handle is a model handle (works with
3383/// `aimux_generate_text` / `aimux_stream_text`); free it with
3384/// `aimux_drop_handle`.
3385///
3386/// `reference_handles` is an array of `ref_len` existing model handles (may be
3387/// 0 — MoaModel then degrades to running just the aggregator). `aggregator`
3388/// is a single existing model handle. `config_json` is a serialized `MoaConfig`
3389/// (all fields optional): `{ "provider_name": "moa", "model_id": "moa",
3390/// "aggregator_instructions": null, "strip_reference_tools": true,
3391/// "fail_mode": "best_effort" | "fail_fast" }`.
3392///
3393/// Fails on: bad JSON, an unknown aggregator handle (references may be empty;
3394/// aggregator may not).
3395#[unsafe(no_mangle)]
3396pub extern "C" fn aimux_moa_new(
3397    reference_handles: *const u64,
3398    ref_len: usize,
3399    aggregator: u64,
3400    config_json: *const c_char,
3401    out_handle: *mut u64,
3402) -> *mut aimux_error_t {
3403    with_out_handle(out_handle, || {
3404        let aggregator_model = get_model(aggregator).ok_or(FfiError::InvalidHandle {
3405            expected: "moa aggregator",
3406        })?;
3407        let references = model_handles(
3408            reference_handles,
3409            ref_len,
3410            "reference_handles",
3411            "moa reference",
3412        )?;
3413        // NULL / empty / "null" all mean "defaults" (matching parse_provider_options
3414        // convention). Invalid UTF-8 → FFI `InvalidUtf8`.
3415        let config_json = normalize_config_json(config_json, "config_json")?;
3416        let cfg: aimux_core::moa::MoaConfig =
3417            serde_json::from_str(&config_json).map_err(|e| wire_err("config_json", e))?;
3418        let model = aimux_core::moa::MoaModel::new(references, aggregator_model, cfg);
3419        Ok(intern_model(Arc::new(model)))
3420    })
3421}
3422
3423/// FFI-side router config (lenient: all fields optional).
3424#[derive(Default, serde::Deserialize)]
3425struct RouterFfiConfig {
3426    router: Option<String>,
3427    weights: Option<Vec<f64>>,
3428    fallback: Option<String>,
3429    provider_name: Option<String>,
3430    model_id: Option<String>,
3431}
3432
3433#[cfg(test)]
3434mod tests {
3435    use aimux_core::{ApiCallError, RetryError, RetryErrorReason};
3436    use std::time::Duration;
3437
3438    use super::*;
3439
3440    /// Own the string a getter returns; None for NULL.
3441    fn take(p: *mut c_char) -> Option<String> {
3442        if p.is_null() {
3443            return None;
3444        }
3445        let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap().to_string();
3446        unsafe { aimux_free_string(p) };
3447        Some(s)
3448    }
3449
3450    /// The owned returned error a failing call would return for `p`.
3451    fn boxed(p: impl Into<AiMuxFfiError>) -> *mut aimux_error_t {
3452        finish::<()>(Err(p.into()), |()| {})
3453    }
3454
3455    /// Message of a returned error, which is then freed.
3456    fn msg(e: *mut aimux_error_t) -> String {
3457        assert!(!e.is_null(), "expected a returned error");
3458        let m = take(aimux_error_message(e)).unwrap();
3459        aimux_error_free(e);
3460        m
3461    }
3462
3463    /// AiMuxError code and message; the returned error is freed.
3464    fn expect_aimux_error(e: *mut aimux_error_t) -> (i32, String) {
3465        assert!(!e.is_null(), "expected a returned error");
3466        let code = aimux_error_code(e);
3467        if !(AIMUX_E_OTHER..=AIMUX_E_TOOL_CALL_REPAIR).contains(&code) {
3468            panic!("expected an AiMuxError code, got {code}: {}", msg(e));
3469        }
3470        let out = (code, take(aimux_error_message(e)).unwrap());
3471        aimux_error_free(e);
3472        out
3473    }
3474
3475    /// C ABI failure code and non-empty message; the returned error is freed.
3476    fn expect_ffi_error(e: *mut aimux_error_t) -> String {
3477        assert!(!e.is_null(), "expected a returned error");
3478        let code = aimux_error_code(e);
3479        assert!(
3480            (AIMUX_E_FFI_NULL_POINTER..=AIMUX_E_FFI_CALLBACK_FAILURE).contains(&code),
3481            "expected a C ABI failure code, got {code}"
3482        );
3483        let m = msg(e);
3484        assert!(!m.is_empty());
3485        m
3486    }
3487
3488    fn zero_err() -> u64 {
3489        0
3490    }
3491
3492    /// Each payload getter answers under the one code that owns it and is
3493    /// NULL / -1 / 0 under every other; a NULL pointer answers as "no error".
3494    #[test]
3495    fn payload_getters_follow_the_code() {
3496        let owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError {
3497            status_code: Some(429),
3498            provider_code: Some("insufficient_quota".into()),
3499            response_body: Some("{}".into()),
3500            response_headers: Some(std::collections::HashMap::from([(
3501                "retry-after-ms".to_string(),
3502                "1500".to_string(),
3503            )])),
3504            data: Some(serde_json::json!({"code": "insufficient_quota"})),
3505            is_retryable: true,
3506            ..ApiCallError::new(
3507                "quota",
3508                "https://api.example.test/v1",
3509                serde_json::json!({"model": "m"}),
3510            )
3511        })));
3512        let h = owner;
3513        assert_eq!(aimux_error_code(h), AIMUX_E_API_CALL);
3514        assert_eq!(aimux_error_status(h), 429);
3515        // The retry hint is derived from the response headers.
3516        assert_eq!(aimux_error_retry_ms(h), 1500);
3517        assert_eq!(aimux_error_retryable(h), 1);
3518        assert_eq!(
3519            take(aimux_error_provider_code(h)).as_deref(),
3520            Some("insufficient_quota")
3521        );
3522        // The variant's own text, not the composed Display form in `message`.
3523        assert_eq!(
3524            take(aimux_error_provider_message(h)).as_deref(),
3525            Some("quota")
3526        );
3527        let m = take(aimux_error_message(h)).unwrap();
3528        assert!(m.contains("quota") && m != "quota", "{m}");
3529        assert_eq!(take(aimux_error_response_body(h)).as_deref(), Some("{}"));
3530        assert_eq!(
3531            take(aimux_error_url(h)).as_deref(),
3532            Some("https://api.example.test/v1")
3533        );
3534        assert_eq!(
3535            take(aimux_error_request_body_values(h)).as_deref(),
3536            Some(r#"{"model":"m"}"#)
3537        );
3538        assert_eq!(
3539            take(aimux_error_response_headers(h)).as_deref(),
3540            Some(r#"{"retry-after-ms":"1500"}"#)
3541        );
3542        assert_eq!(
3543            take(aimux_error_provider_data(h)).as_deref(),
3544            Some(r#"{"code":"insufficient_quota"}"#)
3545        );
3546        assert!(aimux_error_model_id(h).is_null());
3547        assert!(aimux_error_model_type(h).is_null());
3548        assert!(aimux_error_provider_id(h).is_null());
3549        // Retry getters answer their sentinels under AIMUX_E_API_CALL.
3550        assert!(aimux_error_retry_reason(h).is_null());
3551        assert_eq!(aimux_error_retry_count(h), 0);
3552        assert!(aimux_error_retry_error_at(h, 0).is_null());
3553        aimux_error_free(owner);
3554
3555        // Absent Option fields are NULL, not empty strings; a JSON `null`
3556        // request body is NULL too.
3557        let owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError::new(
3558            "x",
3559            "https://api.example.test/v1",
3560            serde_json::Value::Null,
3561        ))));
3562        let h = owner;
3563        assert!(aimux_error_provider_code(h).is_null());
3564        assert!(aimux_error_response_body(h).is_null());
3565        assert!(aimux_error_response_headers(h).is_null());
3566        assert!(aimux_error_provider_data(h).is_null());
3567        assert!(aimux_error_request_body_values(h).is_null());
3568        assert_eq!(take(aimux_error_provider_message(h)).as_deref(), Some("x"));
3569        aimux_error_free(owner);
3570
3571        // Retry owns reason + attempt history; each history entry crosses the
3572        // ABI as a new owned error read with the same getters.
3573        let owner = boxed(AiMuxError::Retry(RetryError {
3574            reason: RetryErrorReason::MaxRetriesExceeded,
3575            errors: vec![
3576                AiMuxError::Other("first".into()),
3577                AiMuxError::ApiCall(Box::new(ApiCallError {
3578                    status_code: Some(500),
3579                    is_retryable: true,
3580                    ..ApiCallError::new(
3581                        "boom",
3582                        "https://api.example.test/v1",
3583                        serde_json::Value::Null,
3584                    )
3585                })),
3586            ],
3587        }));
3588        let h = owner;
3589        assert_eq!(aimux_error_code(h), AIMUX_E_RETRY);
3590        assert_eq!(
3591            take(aimux_error_retry_reason(h)).as_deref(),
3592            Some("maxRetriesExceeded")
3593        );
3594        assert_eq!(aimux_error_retry_count(h), 2);
3595        // ApiCall-owned facts stay with the attempt that owns them.
3596        assert_eq!(aimux_error_status(h), -1);
3597        assert_eq!(aimux_error_retryable(h), 0);
3598        assert!(aimux_error_provider_code(h).is_null());
3599        let m = take(aimux_error_message(h)).unwrap();
3600        assert!(m.contains("Failed after 2 attempts"), "{m}");
3601        let first = aimux_error_retry_error_at(h, 0);
3602        assert_eq!(aimux_error_code(first), AIMUX_E_OTHER);
3603        assert_eq!(take(aimux_error_message(first)).as_deref(), Some("first"));
3604        aimux_error_free(first);
3605        let last = aimux_error_retry_error_at(h, 1);
3606        assert_eq!(aimux_error_code(last), AIMUX_E_API_CALL);
3607        assert_eq!(aimux_error_status(last), 500);
3608        assert_eq!(aimux_error_retryable(last), 1);
3609        // The history entry outlives the parent: free order is unconstrained.
3610        aimux_error_free(owner);
3611        assert_eq!(
3612            take(aimux_error_provider_message(last)).as_deref(),
3613            Some("boom")
3614        );
3615        aimux_error_free(last);
3616        let owner = boxed(AiMuxError::Retry(RetryError {
3617            reason: RetryErrorReason::ErrorNotRetryable,
3618            errors: vec![AiMuxError::Other("bad".into())],
3619        }));
3620        let h = owner;
3621        assert_eq!(
3622            take(aimux_error_retry_reason(h)).as_deref(),
3623            Some("errorNotRetryable")
3624        );
3625        assert!(aimux_error_retry_error_at(h, 1).is_null(), "out of range");
3626        assert!(aimux_error_retry_error_at(h, -1).is_null());
3627        aimux_error_free(owner);
3628
3629        let owner = boxed(AiMuxError::NoSuchModel {
3630            model_id: "m".into(),
3631            model_type: "language".into(),
3632        });
3633        let h = owner;
3634        assert_eq!(aimux_error_code(h), AIMUX_E_NO_SUCH_MODEL);
3635        assert_eq!(take(aimux_error_model_id(h)).as_deref(), Some("m"));
3636        assert_eq!(take(aimux_error_model_type(h)).as_deref(), Some("language"));
3637        assert!(aimux_error_provider_code(h).is_null());
3638        assert!(aimux_error_provider_id(h).is_null());
3639        assert_eq!(aimux_error_status(h), -1);
3640        assert_eq!(aimux_error_retryable(h), 0);
3641        aimux_error_free(owner);
3642
3643        let owner = boxed(AiMuxError::NoSuchProvider {
3644            provider_id: "p".into(),
3645        });
3646        let h = owner;
3647        assert_eq!(aimux_error_code(h), AIMUX_E_NO_SUCH_PROVIDER);
3648        assert_eq!(take(aimux_error_provider_id(h)).as_deref(), Some("p"));
3649        assert!(aimux_error_model_id(h).is_null());
3650        aimux_error_free(owner);
3651
3652        // NULL pointer: never UB, answers "no error".
3653        let n = std::ptr::null();
3654        assert_eq!(aimux_error_code(n), AIMUX_OK);
3655        assert!(aimux_error_message(n).is_null());
3656        assert_eq!(aimux_error_status(n), -1);
3657        assert_eq!(aimux_error_retry_ms(n), -1);
3658        assert_eq!(aimux_error_retryable(n), 0);
3659        assert!(aimux_error_provider_code(n).is_null());
3660        assert!(aimux_error_url(n).is_null());
3661        assert!(aimux_error_request_body_values(n).is_null());
3662        assert!(aimux_error_response_headers(n).is_null());
3663        assert!(aimux_error_provider_data(n).is_null());
3664        assert!(aimux_error_retry_reason(n).is_null());
3665        assert_eq!(aimux_error_retry_count(n), 0);
3666        assert!(aimux_error_retry_error_at(n, 0).is_null());
3667    }
3668
3669    /// The retry verdict crosses the ABI as its own getter. It is not
3670    /// derivable from `status`: both cases below report -1, and they disagree.
3671    #[test]
3672    fn retryable_crosses_the_abi_and_status_cannot_stand_in() {
3673        let transport_owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError {
3674            is_retryable: true,
3675            ..ApiCallError::new(
3676                "connection reset",
3677                "https://api.example.test/v1",
3678                serde_json::Value::Null,
3679            )
3680        })));
3681        let transport = transport_owner;
3682        assert_eq!(aimux_error_status(transport), -1);
3683        assert_eq!(aimux_error_retryable(transport), 1);
3684        aimux_error_free(transport_owner);
3685
3686        let no_key_owner = boxed(AiMuxError::ApiCall(Box::new(ApiCallError::new(
3687            "no api key",
3688            "https://api.example.test/v1",
3689            serde_json::Value::Null,
3690        ))));
3691        let no_key = no_key_owner;
3692        assert_eq!(
3693            aimux_error_status(no_key),
3694            -1,
3695            "same sentinel as the transport failure"
3696        );
3697        assert_eq!(aimux_error_retryable(no_key), 0, "but the opposite verdict");
3698        aimux_error_free(no_key_owner);
3699
3700        // Non-ApiCall variants are never retryable.
3701        let arg_owner = boxed(AiMuxError::InvalidArgument("bad".into()));
3702        let arg = arg_owner;
3703        assert_eq!(aimux_error_retryable(arg), 0);
3704        aimux_error_free(arg_owner);
3705    }
3706
3707    /// The unified code space identifies every returned error source.
3708    #[test]
3709    fn unified_error_codes_cover_every_source() {
3710        // AiMuxError.
3711        let e = boxed(AiMuxError::Aborted("request aborted".into()));
3712        assert_eq!(aimux_error_code(e), AIMUX_E_ABORTED);
3713        assert_eq!(msg(e), "request aborted");
3714
3715        // Retry fills slot 14 between Aborted and the tool-call codes.
3716        let e = boxed(AiMuxError::Retry(RetryError {
3717            reason: RetryErrorReason::ErrorNotRetryable,
3718            errors: vec![AiMuxError::Other("bad".into())],
3719        }));
3720        assert_eq!(aimux_error_code(e), AIMUX_E_RETRY);
3721        assert!(msg(e).contains("non-retryable"));
3722
3723        // Recording.
3724        use RecordingError as R;
3725        let cases = [
3726            (R::WriterGone, AIMUX_E_RECORDING_WRITER_GONE),
3727            (R::FlushTimeout, AIMUX_E_RECORDING_FLUSH_TIMEOUT),
3728            (R::Write("ENOSPC".into()), AIMUX_E_RECORDING_WRITE),
3729            (
3730                R::Spawn {
3731                    source: std::io::Error::other("x"),
3732                },
3733                AIMUX_E_RECORDING_SPAWN,
3734            ),
3735        ];
3736        for (r, code) in cases {
3737            let e = boxed(r);
3738            assert_eq!(aimux_error_code(e), code);
3739            assert!(!msg(e).is_empty());
3740        }
3741        // NULL means success.
3742        assert_eq!(aimux_error_code(std::ptr::null()), AIMUX_OK);
3743        assert!(aimux_error_message(std::ptr::null()).is_null());
3744
3745        // FfiError codes and Display texts are pinned.
3746        let cases: Vec<(FfiError, i32, &str)> = vec![
3747            (
3748                FfiError::NullPointer {
3749                    argument: "api_key",
3750                },
3751                AIMUX_E_FFI_NULL_POINTER,
3752                "api_key: must not be NULL",
3753            ),
3754            (
3755                FfiError::InvalidUtf8 {
3756                    argument: "model_id",
3757                },
3758                AIMUX_E_FFI_INVALID_UTF8,
3759                "model_id: must be valid UTF-8",
3760            ),
3761            (
3762                FfiError::InvalidWireJson {
3763                    argument: "opts_json",
3764                    message: "expected `,`".into(),
3765                },
3766                AIMUX_E_FFI_INVALID_WIRE_JSON,
3767                "opts_json: invalid JSON: expected `,`",
3768            ),
3769            (
3770                FfiError::InvalidHandle { expected: "model" },
3771                AIMUX_E_FFI_INVALID_HANDLE,
3772                "invalid or expired model handle",
3773            ),
3774            (
3775                FfiError::ReentrantCall,
3776                AIMUX_E_FFI_REENTRANT_CALL,
3777                "re-entrant FFI call from within a callback is not allowed",
3778            ),
3779            (
3780                FfiError::ResultSerialization {
3781                    message: "serialize".into(),
3782                },
3783                AIMUX_E_FFI_RESULT_SERIALIZATION,
3784                "could not serialize result: serialize",
3785            ),
3786            (
3787                FfiError::CallbackFailure {
3788                    message: "on_part panicked".into(),
3789                },
3790                AIMUX_E_FFI_CALLBACK_FAILURE,
3791                "host callback failed: on_part panicked",
3792            ),
3793        ];
3794        for (f, code, text) in cases {
3795            let e = boxed(f);
3796            assert_eq!(aimux_error_code(e), code);
3797            assert_eq!(expect_ffi_error(e), text);
3798        }
3799
3800        // NULL owner: every accessor is NULL-safe.
3801        let n: *mut aimux_error_t = std::ptr::null_mut();
3802        assert_eq!(aimux_error_code(n), AIMUX_OK);
3803        assert!(aimux_error_message(n).is_null());
3804        aimux_error_free(n);
3805    }
3806
3807    /// Even on a recording entry point, malformed C input is an `FfiError`;
3808    /// only failures produced by `JsonlRecorder` carry the recording
3809    /// code.
3810    #[test]
3811    fn recording_init_reports_ffi_codes_for_invalid_input() {
3812        assert_eq!(
3813            expect_ffi_error(aimux_init_recording(std::ptr::null())),
3814            "dir: must not be NULL"
3815        );
3816        let invalid_utf8 = [0xff_u8, 0];
3817        assert_eq!(
3818            expect_ffi_error(aimux_init_recording(invalid_utf8.as_ptr().cast())),
3819            "dir: must be valid UTF-8"
3820        );
3821    }
3822
3823    /// Pin the full 16-variant → code mapping.
3824    #[test]
3825    fn error_code_mapping_covers_all_variants() {
3826        let s = |t: &str| t.to_string();
3827        let cases: Vec<(AiMuxError, i32)> = vec![
3828            (
3829                AiMuxError::ApiCall(Box::new(ApiCallError::new(
3830                    "x",
3831                    "https://example.test",
3832                    serde_json::json!({}),
3833                ))),
3834                AIMUX_E_API_CALL,
3835            ),
3836            (
3837                AiMuxError::Retry(RetryError {
3838                    reason: RetryErrorReason::MaxRetriesExceeded,
3839                    errors: vec![AiMuxError::Other(s("first")), AiMuxError::Other(s("last"))],
3840                }),
3841                AIMUX_E_RETRY,
3842            ),
3843            (AiMuxError::JsonParse(s("x")), AIMUX_E_JSON_PARSE),
3844            (
3845                AiMuxError::InvalidResponseData(s("x")),
3846                AIMUX_E_INVALID_RESPONSE_DATA,
3847            ),
3848            (
3849                AiMuxError::NoSuchTool {
3850                    tool_name: s("t"),
3851                    available_tools: None,
3852                    tool_input: None,
3853                },
3854                AIMUX_E_NO_SUCH_TOOL,
3855            ),
3856            (
3857                AiMuxError::InvalidToolInput {
3858                    tool_name: s("t"),
3859                    tool_input: s("{}"),
3860                    cause: s("x"),
3861                },
3862                AIMUX_E_INVALID_TOOL_INPUT,
3863            ),
3864            (
3865                AiMuxError::ToolCallRepair {
3866                    original_error: Box::new(AiMuxError::NoSuchTool {
3867                        tool_name: s("t"),
3868                        available_tools: None,
3869                        tool_input: None,
3870                    }),
3871                    cause: Box::new(AiMuxError::Other(s("x"))),
3872                },
3873                AIMUX_E_TOOL_CALL_REPAIR,
3874            ),
3875            (
3876                AiMuxError::InvalidArgument(s("x")),
3877                AIMUX_E_INVALID_ARGUMENT,
3878            ),
3879            (AiMuxError::InvalidPrompt(s("x")), AIMUX_E_INVALID_PROMPT),
3880            (AiMuxError::TokenExpired(s("x")), AIMUX_E_TOKEN_EXPIRED),
3881            (
3882                AiMuxError::UnsupportedFunctionality(s("x")),
3883                AIMUX_E_UNSUPPORTED_FUNCTIONALITY,
3884            ),
3885            (
3886                AiMuxError::NoSuchModel {
3887                    model_id: s("x"),
3888                    model_type: String::new(),
3889                },
3890                AIMUX_E_NO_SUCH_MODEL,
3891            ),
3892            (
3893                AiMuxError::NoSuchProvider {
3894                    provider_id: s("x"),
3895                },
3896                AIMUX_E_NO_SUCH_PROVIDER,
3897            ),
3898            (AiMuxError::Timeout(s("x")), AIMUX_E_TIMEOUT),
3899            (
3900                AiMuxError::Aborted("request aborted".into()),
3901                AIMUX_E_ABORTED,
3902            ),
3903            (AiMuxError::Other(s("x")), AIMUX_E_OTHER),
3904        ];
3905        for (e, code) in cases {
3906            let expect = format!("variant {e:?}");
3907            let (got, _) = expect_aimux_error(boxed(e));
3908            assert_eq!(got, code, "{expect}");
3909        }
3910    }
3911
3912    #[test]
3913    fn unknown_tool_error_exposes_original_argument_text() {
3914        let raw = r#""hello""#;
3915        let error = boxed(AiMuxError::NoSuchTool {
3916            tool_name: "unknown".into(),
3917            available_tools: None,
3918            tool_input: Some(raw.into()),
3919        });
3920        let text = aimux_error_tool_input(error);
3921        assert!(!text.is_null());
3922        // The accessor returns an owned C string, released with the public API.
3923        assert_eq!(unsafe { CStr::from_ptr(text) }.to_str().unwrap(), raw);
3924        unsafe { aimux_free_string(text) };
3925        aimux_error_free(error);
3926    }
3927
3928    /// Interior NUL bytes must not corrupt or truncate the message.
3929    #[test]
3930    fn error_message_sanitizes_interior_nul() {
3931        let (_, m) = expect_aimux_error(boxed(AiMuxError::Other("a\0b".into())));
3932        assert_eq!(m, "a\u{FFFD}b");
3933    }
3934
3935    /// A NULL out-parameter is reported, not dereferenced.
3936    #[test]
3937    fn null_out_param_is_an_ffi_error() {
3938        let key = std::ffi::CString::new("k").unwrap();
3939        assert_eq!(
3940            expect_ffi_error(aimux_openai_new(
3941                key.as_ptr(),
3942                key.as_ptr(),
3943                std::ptr::null_mut()
3944            )),
3945            "out_handle: must not be NULL"
3946        );
3947        assert_eq!(
3948            expect_ffi_error(aimux_list_sessions(std::ptr::null_mut())),
3949            "out_json: must not be NULL"
3950        );
3951    }
3952
3953    // ── invoke_stream_callback (issue #64: FFI panic guard) ───────────────────
3954
3955    #[test]
3956    fn stream_callback_ok_passthrough() {
3957        // A callback that returns normally must pass through as Ok.
3958        let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3959        let called_clone = called.clone();
3960        let result = invoke_stream_callback("on_part", || {
3961            called_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3962        });
3963        assert!(result.is_ok());
3964        assert_eq!(
3965            called.load(std::sync::atomic::Ordering::SeqCst),
3966            1,
3967            "callback must have been invoked"
3968        );
3969    }
3970
3971    #[test]
3972    fn stream_callback_catches_str_panic() {
3973        // A panic with a &'static str payload must be caught and converted.
3974        let result = invoke_stream_callback("on_part", || {
3975            panic!("callback explosion");
3976        });
3977        match result {
3978            Err(FfiError::CallbackFailure { message: msg }) => {
3979                assert!(
3980                    msg.contains("on_part"),
3981                    "message should name the callback: {msg}"
3982                );
3983                assert!(
3984                    msg.contains("callback explosion"),
3985                    "message should include the panic payload: {msg}"
3986                );
3987            }
3988            other => panic!("expected FfiError::CallbackFailure, got {other:?}"),
3989        }
3990    }
3991
3992    #[test]
3993    fn stream_callback_catches_string_panic() {
3994        // A panic with a String payload must also be caught.
3995        let result = invoke_stream_callback("on_done", || {
3996            panic!("{}", "dynamic boom".to_string());
3997        });
3998        match result {
3999            Err(FfiError::CallbackFailure { message: msg }) => {
4000                assert!(
4001                    msg.contains("on_done"),
4002                    "message should name the callback: {msg}"
4003                );
4004                assert!(
4005                    msg.contains("dynamic boom"),
4006                    "message should include the panic payload: {msg}"
4007                );
4008            }
4009            other => panic!("expected FfiError::CallbackFailure, got {other:?}"),
4010        }
4011    }
4012
4013    #[test]
4014    fn stream_callback_catches_non_string_panic() {
4015        // A panic with a non-string payload (e.g. a struct) must still be
4016        // caught — the message falls back to the placeholder.
4017        let result = invoke_stream_callback("on_part", || {
4018            std::panic::panic_any(42i32);
4019        });
4020        match result {
4021            Err(FfiError::CallbackFailure { message: msg }) => {
4022                assert!(
4023                    msg.contains("on_part"),
4024                    "message should name the callback: {msg}"
4025                );
4026                assert!(
4027                    msg.contains("<non-string panic>"),
4028                    "non-string payload should use placeholder: {msg}"
4029                );
4030            }
4031            other => panic!("expected FfiError::CallbackFailure, got {other:?}"),
4032        }
4033    }
4034
4035    #[test]
4036    fn stream_callback_catches_on_done_panic() {
4037        // Symmetric coverage for the on_done callback path (the other panic
4038        // tests exercise on_part). The guard is identical, but this pins the
4039        // on_done name in the error message.
4040        let result = invoke_stream_callback("on_done", || {
4041            panic!("done callback failed");
4042        });
4043        match result {
4044            Err(FfiError::CallbackFailure { message: msg }) => {
4045                assert!(
4046                    msg.contains("on_done"),
4047                    "message should name the callback: {msg}"
4048                );
4049                assert!(
4050                    msg.contains("done callback failed"),
4051                    "message should include the panic payload: {msg}"
4052                );
4053            }
4054            other => panic!("expected FfiError::CallbackFailure, got {other:?}"),
4055        }
4056    }
4057
4058    /// A tiny mock model for composite-FFI tests (returns fixed text).
4059    struct MockText {
4060        provider: &'static str,
4061        model_id: &'static str,
4062        text: &'static str,
4063    }
4064    #[async_trait::async_trait]
4065    impl aimux_core::LanguageModel for MockText {
4066        fn provider(&self) -> &str {
4067            self.provider
4068        }
4069        fn model_id(&self) -> &str {
4070            self.model_id
4071        }
4072        async fn do_generate(
4073            &self,
4074            _options: &aimux_core::options::CallOptions,
4075        ) -> Result<aimux_core::result::GenerateResult, AiMuxError> {
4076            Ok(aimux_core::result::GenerateResult {
4077                content: vec![aimux_core::result::GenerateContent::Text {
4078                    text: self.text.into(),
4079                    provider_metadata: None,
4080                }],
4081                finish_reason: aimux_core::types::FinishReason {
4082                    unified: aimux_core::types::FinishReasonUnified::Stop,
4083                    raw: None,
4084                },
4085                usage: aimux_core::types::Usage::default(),
4086                warnings: vec![],
4087                provider_metadata: None,
4088                response: aimux_core::types::ResponseMetadata {
4089                    model_id: Some(self.model_id.into()),
4090                    ..Default::default()
4091                },
4092                request_body: None,
4093                response_headers: None,
4094            })
4095        }
4096        async fn do_stream(
4097            &self,
4098            _options: &aimux_core::options::CallOptions,
4099        ) -> Result<aimux_core::result::StreamResult, AiMuxError> {
4100            unimplemented!()
4101        }
4102    }
4103
4104    fn mock_handle(provider: &'static str, model_id: &'static str, text: &'static str) -> u64 {
4105        intern_model(Arc::new(MockText {
4106            provider,
4107            model_id,
4108            text,
4109        }))
4110    }
4111
4112    /// Streams: delta, recoverable Err(JsonParse), delta, Finish.
4113    struct RecoverableFrameModel;
4114    #[async_trait::async_trait]
4115    impl aimux_core::LanguageModel for RecoverableFrameModel {
4116        fn provider(&self) -> &str {
4117            "mock"
4118        }
4119        fn model_id(&self) -> &str {
4120            "recoverable"
4121        }
4122        async fn do_generate(
4123            &self,
4124            _options: &aimux_core::options::CallOptions,
4125        ) -> Result<aimux_core::result::GenerateResult, AiMuxError> {
4126            unimplemented!()
4127        }
4128        async fn do_stream(
4129            &self,
4130            _options: &aimux_core::options::CallOptions,
4131        ) -> Result<aimux_core::result::StreamResult, AiMuxError> {
4132            let delta = |text: &str| aimux_core::stream_part::StreamPart::TextDelta {
4133                id: "1".into(),
4134                delta: text.into(),
4135                provider_metadata: None,
4136            };
4137            Ok(aimux_core::result::StreamResult {
4138                stream: Box::pin(futures::stream::iter([
4139                    Ok(delta("a")),
4140                    Err(AiMuxError::JsonParse("bad frame".into())),
4141                    Ok(delta("b")),
4142                    Ok(aimux_core::stream_part::StreamPart::Finish {
4143                        finish_reason: aimux_core::types::FinishReason {
4144                            unified: aimux_core::types::FinishReasonUnified::Stop,
4145                            raw: None,
4146                        },
4147                        usage: aimux_core::types::Usage::default(),
4148                        provider_metadata: None,
4149                    }),
4150                ])),
4151                request_body: None,
4152                response_headers: None,
4153            })
4154        }
4155    }
4156
4157    /// The pump must deliver a recoverable frame error as a StreamPart::Error
4158    /// data part and keep going — parts after it still arrive and on_done
4159    /// fires (the pre-fix pump returned the error and skipped on_done).
4160    #[test]
4161    fn recoverable_frame_error_does_not_end_the_ffi_stream() {
4162        struct Collected {
4163            parts: Vec<String>,
4164            done: bool,
4165        }
4166        extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) {
4167            let collected = unsafe { &mut *(ctx as *mut Collected) };
4168            let text = unsafe { std::ffi::CStr::from_ptr(json) }
4169                .to_string_lossy()
4170                .into_owned();
4171            collected.parts.push(text);
4172        }
4173        extern "C-unwind" fn on_done(ctx: *mut c_void) {
4174            let collected = unsafe { &mut *(ctx as *mut Collected) };
4175            collected.done = true;
4176        }
4177
4178        let handle = intern_model(Arc::new(RecoverableFrameModel));
4179        let mut collected = Collected {
4180            parts: Vec::new(),
4181            done: false,
4182        };
4183        let prompt = std::ffi::CString::new("\"hi\"").unwrap();
4184        let e = aimux_stream_text(
4185            handle,
4186            prompt.as_ptr(),
4187            std::ptr::null(),
4188            Some(on_part),
4189            Some(on_done),
4190            &mut collected as *mut Collected as *mut c_void,
4191        );
4192        assert!(e.is_null(), "{}", msg(e));
4193        assert!(
4194            collected.done,
4195            "on_done must fire after a recoverable frame"
4196        );
4197        let error_index = collected
4198            .parts
4199            .iter()
4200            .position(|p| p.contains("\"Error\""))
4201            .expect("the recoverable frame arrives as a StreamPart::Error part");
4202        assert!(
4203            collected.parts[error_index + 1..]
4204                .iter()
4205                .any(|p| p.contains("\"b\"")),
4206            "parts after the recoverable frame still arrive: {:?}",
4207            collected.parts
4208        );
4209    }
4210
4211    /// On the OpenAI-compatible path every item is a ChatCompletionChunk, so a
4212    /// recoverable frame error is skipped — no error item, no truncation, and
4213    /// on_done still fires.
4214    #[test]
4215    fn recoverable_frame_error_is_skipped_on_the_openai_stream() {
4216        struct Collected {
4217            parts: Vec<String>,
4218            done: bool,
4219        }
4220        extern "C-unwind" fn on_part(json: *const c_char, ctx: *mut c_void) {
4221            let collected = unsafe { &mut *(ctx as *mut Collected) };
4222            let text = unsafe { std::ffi::CStr::from_ptr(json) }
4223                .to_string_lossy()
4224                .into_owned();
4225            collected.parts.push(text);
4226        }
4227        extern "C-unwind" fn on_done(ctx: *mut c_void) {
4228            let collected = unsafe { &mut *(ctx as *mut Collected) };
4229            collected.done = true;
4230        }
4231
4232        let handle = intern_model(Arc::new(RecoverableFrameModel));
4233        let mut collected = Collected {
4234            parts: Vec::new(),
4235            done: false,
4236        };
4237        let prompt = std::ffi::CString::new("\"hi\"").unwrap();
4238        let e = aimux_stream_text_as_openai(
4239            handle,
4240            prompt.as_ptr(),
4241            std::ptr::null(),
4242            Some(on_part),
4243            Some(on_done),
4244            &mut collected as *mut Collected as *mut c_void,
4245        );
4246        assert!(e.is_null(), "{}", msg(e));
4247        assert!(
4248            collected.done,
4249            "on_done must fire after a recoverable frame"
4250        );
4251        assert!(
4252            collected.parts.iter().all(|p| !p.contains("\"error\"")),
4253            "no error item may ride the chunk-typed wire: {:?}",
4254            collected.parts
4255        );
4256        assert!(
4257            collected.parts.iter().any(|p| p.contains("\"b\"")),
4258            "chunks after the recoverable frame still arrive: {:?}",
4259            collected.parts
4260        );
4261    }
4262
4263    #[test]
4264    fn router_new_builds_router_model() {
4265        let handles = [
4266            mock_handle("mock", "primary", "primary-out"),
4267            mock_handle("mock", "backup", "backup-out"),
4268        ];
4269        let mut h = zero_err();
4270        let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h);
4271        assert!(e.is_null(), "{}", msg(e));
4272        assert!(h != 0, "router handle must be non-zero");
4273        // Confirm it resolves to a model.
4274        let model = get_model(h).expect("router handle resolves");
4275        assert_eq!(model.provider(), "router");
4276        assert_eq!(model.model_id(), "router");
4277    }
4278
4279    #[test]
4280    fn router_new_rejects_empty_handles() {
4281        // len == 0 (with or without a NULL array) is "no usable child", not a
4282        // null pointer; a NULL array with len > 0 is.
4283        let mut h = zero_err();
4284        let e = aimux_router_new(std::ptr::null(), 0, std::ptr::null(), &mut h);
4285        assert_eq!(h, 0);
4286        assert_eq!(
4287            expect_ffi_error(e),
4288            "invalid or expired router child handle"
4289        );
4290        let e = aimux_router_new(std::ptr::null(), 2, std::ptr::null(), &mut h);
4291        assert_eq!(h, 0);
4292        assert_eq!(expect_ffi_error(e), "handles: must not be NULL");
4293    }
4294
4295    #[test]
4296    fn router_new_rejects_bad_json() {
4297        let handles = [mock_handle("mock", "m", "x")];
4298        let bad = std::ffi::CString::new("{not json").unwrap();
4299        let mut h = zero_err();
4300        let e = aimux_router_new(handles.as_ptr(), handles.len(), bad.as_ptr(), &mut h);
4301        assert_eq!(h, 0);
4302        assert!(expect_ffi_error(e).starts_with("config_json: invalid JSON:"));
4303    }
4304
4305    #[test]
4306    fn router_new_treats_empty_config_as_defaults() {
4307        // S1 guard: empty string / "null" config must NOT be a JSON_PARSE error
4308        // (consistent with other config-bearing FFI entry points).
4309        let handles = [mock_handle("mock", "m", "x")];
4310        for cfg in ["", "  ", "null"] {
4311            let c = std::ffi::CString::new(cfg).unwrap();
4312            let mut h = zero_err();
4313            let e = aimux_router_new(handles.as_ptr(), handles.len(), c.as_ptr(), &mut h);
4314            assert!(e.is_null(), "router with config {cfg:?}: {}", msg(e));
4315            assert!(h != 0);
4316        }
4317    }
4318
4319    #[test]
4320    fn moa_new_builds_moa_model() {
4321        let refs = [
4322            mock_handle("mock", "ref-a", "A"),
4323            mock_handle("mock", "ref-b", "B"),
4324        ];
4325        let agg = mock_handle("mock", "aggregator", "agg");
4326        let mut h = zero_err();
4327        let e = aimux_moa_new(refs.as_ptr(), refs.len(), agg, std::ptr::null(), &mut h);
4328        assert!(e.is_null(), "{}", msg(e));
4329        assert!(h != 0, "moa handle must be non-zero");
4330        let model = get_model(h).expect("moa handle resolves");
4331        assert_eq!(model.provider(), "moa");
4332        assert_eq!(model.model_id(), "moa");
4333    }
4334
4335    #[test]
4336    fn moa_new_rejects_bad_aggregator() {
4337        let refs = [mock_handle("mock", "ref-a", "A")];
4338        let mut h = zero_err();
4339        // aggregator handle 999999 does not exist.
4340        let e = aimux_moa_new(refs.as_ptr(), refs.len(), 999_999, std::ptr::null(), &mut h);
4341        assert_eq!(h, 0);
4342        assert_eq!(
4343            expect_ffi_error(e),
4344            "invalid or expired moa aggregator handle"
4345        );
4346    }
4347
4348    #[test]
4349    fn composites_reject_any_dead_member_handle() {
4350        // A dead handle inside the array is a caller bug: reported, never
4351        // silently dropped (the composite would otherwise run with fewer
4352        // members than the caller believes).
4353        let live = mock_handle("mock", "live", "L");
4354        let dead = mock_handle("mock", "dead", "D");
4355        aimux_drop_handle(dead);
4356        let handles = [live, dead];
4357
4358        let mut h = zero_err();
4359        let e = aimux_router_new(handles.as_ptr(), handles.len(), std::ptr::null(), &mut h);
4360        assert_eq!(h, 0);
4361        assert_eq!(
4362            expect_ffi_error(e),
4363            "invalid or expired router child handle"
4364        );
4365
4366        let agg = mock_handle("mock", "aggregator", "agg");
4367        let e = aimux_moa_new(
4368            handles.as_ptr(),
4369            handles.len(),
4370            agg,
4371            std::ptr::null(),
4372            &mut h,
4373        );
4374        assert_eq!(h, 0);
4375        assert_eq!(
4376            expect_ffi_error(e),
4377            "invalid or expired moa reference handle"
4378        );
4379
4380        // NULL with ref_len > 0 is a null pointer, same as router.
4381        let e = aimux_moa_new(std::ptr::null(), 2, agg, std::ptr::null(), &mut h);
4382        assert_eq!(h, 0);
4383        assert_eq!(expect_ffi_error(e), "reference_handles: must not be NULL");
4384    }
4385
4386    #[test]
4387    fn moa_new_allows_zero_references() {
4388        // 0 references is valid (degrades to aggregator-only).
4389        let agg = mock_handle("mock", "aggregator", "agg");
4390        let mut h = zero_err();
4391        let e = aimux_moa_new(std::ptr::null(), 0, agg, std::ptr::null(), &mut h);
4392        assert!(e.is_null(), "{}", msg(e));
4393        assert!(h != 0, "moa with 0 references should succeed");
4394    }
4395
4396    // ── Transcription streaming sessions (RFC-0028 Phase 2) ──────────────
4397
4398    /// A mock transcription model whose `do_stream` echoes: one delta per
4399    /// received audio chunk, then final + finish after the audio ends.
4400    /// Honors the abort signal.
4401    struct MockStreamingTranscriber;
4402
4403    #[async_trait::async_trait]
4404    impl aimux_core::transcription_model::TranscriptionModel for MockStreamingTranscriber {
4405        fn provider(&self) -> &str {
4406            "mock"
4407        }
4408        fn model_id(&self) -> &str {
4409            "mock-stream-stt"
4410        }
4411        async fn do_generate(
4412            &self,
4413            _options: &aimux_core::transcription_model::TranscriptionCallOptions,
4414        ) -> Result<aimux_core::transcription_model::TranscriptionResult, AiMuxError> {
4415            unimplemented!()
4416        }
4417        async fn do_stream(
4418            &self,
4419            options: aimux_core::transcription_model::TranscriptionStreamOptions,
4420        ) -> Result<aimux_core::transcription_model::TranscriptionStreamResult, AiMuxError>
4421        {
4422            use aimux_core::transcription_model::{
4423                AudioChunk, TranscriptionStreamPart, TranscriptionStreamResult,
4424            };
4425            use futures::StreamExt;
4426            let mut audio = options.audio;
4427            let abort = options.abort_signal.clone();
4428            let stream = async_stream::stream! {
4429                yield Ok(TranscriptionStreamPart::StreamStart { warnings: vec![] });
4430                let mut text = String::new();
4431                loop {
4432                    let aborted = async {
4433                        match &abort {
4434                            Some(s) => s.cancelled().await,
4435                            None => std::future::pending().await,
4436                        }
4437                    };
4438                    tokio::select! {
4439                        _ = aborted => {
4440                            yield Err(AiMuxError::Aborted("request aborted".into()));
4441                            break;
4442                        }
4443                        chunk = audio.next() => match chunk {
4444                            Some(AudioChunk::Binary(b)) => {
4445                                let s = format!("{} ", b.len());
4446                                text.push_str(&s);
4447                                yield Ok(TranscriptionStreamPart::TranscriptDelta {
4448                                    id: None,
4449                                    delta: s,
4450                                    provider_metadata: None,
4451                                });
4452                            }
4453                            Some(AudioChunk::Base64(s)) => {
4454                                text.push_str(&s);
4455                                yield Ok(TranscriptionStreamPart::TranscriptDelta {
4456                                    id: None,
4457                                    delta: s,
4458                                    provider_metadata: None,
4459                                });
4460                            }
4461                            None => {
4462                                yield Ok(TranscriptionStreamPart::TranscriptFinal {
4463                                    id: None,
4464                                    text: text.clone(),
4465                                    start_second: None,
4466                                    end_second: None,
4467                                    channel_index: None,
4468                                    provider_metadata: None,
4469                                });
4470                                yield Ok(TranscriptionStreamPart::Finish {
4471                                    text,
4472                                    segments: vec![],
4473                                    language: None,
4474                                    duration_in_seconds: None,
4475                                    provider_metadata: None,
4476                                });
4477                                break;
4478                            }
4479                        },
4480                    }
4481                }
4482            };
4483            Ok(TranscriptionStreamResult {
4484                stream: Box::pin(stream),
4485                request: None,
4486                response: None,
4487            })
4488        }
4489    }
4490
4491    fn mock_transcriber_handle() -> u64 {
4492        intern_handle(HandleEntry::Transcription(Arc::new(
4493            MockStreamingTranscriber,
4494        )))
4495    }
4496
4497    fn session(model: u64, abort: u64) -> u64 {
4498        let mut s = zero_err();
4499        let e = aimux_transcription_session_new(model, abort, std::ptr::null(), &mut s);
4500        assert!(e.is_null(), "{}", msg(e));
4501        assert!(s != 0, "session should start");
4502        s
4503    }
4504
4505    /// One `next_part` pull: `(state, part)`; a non-NULL error is returned
4506    /// as `Err`.
4507    fn pull(session: u64, timeout_ms: i64) -> Result<(i32, Option<String>), *mut aimux_error_t> {
4508        let mut part: *mut c_char = std::ptr::null_mut();
4509        let mut state: i32 = 0;
4510        let e = aimux_transcription_next_part(session, timeout_ms, &mut part, &mut state);
4511        if !e.is_null() {
4512            assert!(part.is_null(), "failure must leave *out_part NULL");
4513            return Err(e);
4514        }
4515        Ok((state, take(part)))
4516    }
4517
4518    fn pull_part(session: u64) -> String {
4519        match pull(session, 2_000) {
4520            Ok((AIMUX_TRANSCRIPTION_NEXT_PART_PART, Some(p))) => p,
4521            Ok(other) => panic!("expected a part, got {other:?}"),
4522            Err(e) => panic!("expected a part, got error: {}", msg(e)),
4523        }
4524    }
4525
4526    #[test]
4527    fn transcription_session_full_lifecycle() {
4528        let model = mock_transcriber_handle();
4529        let session = session(model, 0);
4530
4531        // Push two chunks + end-of-input.
4532        let chunk1 = [1u8, 2, 3];
4533        let chunk2 = [4u8, 5];
4534        assert!(aimux_transcription_push_audio(session, chunk1.as_ptr(), 3).is_null());
4535        assert!(aimux_transcription_push_audio(session, chunk2.as_ptr(), 2).is_null());
4536        assert!(aimux_transcription_input_done(session).is_null());
4537
4538        // Pull parts: StreamStart, delta("3 "), delta("2 "), Final, Finish,
4539        // then ENDED.
4540        let p1 = pull_part(session);
4541        assert!(p1.contains("StreamStart"), "part 1: {p1}");
4542        let p2 = pull_part(session);
4543        assert!(p2.contains("3 "), "part 2: {p2}");
4544        let p3 = pull_part(session);
4545        assert!(p3.contains("2 "), "part 3: {p3}");
4546        let p4 = pull_part(session);
4547        assert!(p4.contains("TranscriptFinal"), "part 4: {p4}");
4548        let p5 = pull_part(session);
4549        assert!(p5.contains("Finish"), "part 5: {p5}");
4550        assert!(p5.contains("3 2 "), "finish text: {p5}");
4551
4552        // Stream ended: NULL error, ENDED state, no part.
4553        assert_eq!(
4554            pull(session, 2_000).ok(),
4555            Some((AIMUX_TRANSCRIPTION_NEXT_PART_ENDED, None))
4556        );
4557
4558        aimux_transcription_session_drop(session);
4559    }
4560
4561    #[test]
4562    fn transcription_session_timeout_is_a_state_not_an_error() {
4563        let model = mock_transcriber_handle();
4564        let session = session(model, 0);
4565        // Consume StreamStart, then DON'T push audio: next_part times out
4566        // (the mock waits for more audio).
4567        let _ = pull_part(session);
4568        assert_eq!(
4569            pull(session, 50).ok(),
4570            Some((AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None))
4571        );
4572
4573        // Session still alive: push + finish works after the timeout.
4574        let chunk = [9u8];
4575        assert!(aimux_transcription_push_audio(session, chunk.as_ptr(), 1).is_null());
4576        assert!(aimux_transcription_input_done(session).is_null());
4577        let p = pull_part(session);
4578        assert!(p.contains("1 "), "post-timeout part: {p}");
4579        aimux_transcription_session_drop(session);
4580    }
4581
4582    #[test]
4583    fn transcription_session_abort_via_signal() {
4584        let model = mock_transcriber_handle();
4585        let abort = crate::aimux_abort_signal_new();
4586        let session = session(model, abort);
4587        // Drain StreamStart.
4588        let _ = pull_part(session);
4589
4590        // Abort; the mock yields Err(Aborted).
4591        crate::aimux_abort_signal_abort(abort);
4592        let e = pull(session, 2_000).expect_err("aborted stream must fail");
4593        assert_eq!(expect_aimux_error(e).0, AIMUX_E_ABORTED);
4594
4595        // Push after abort fails or is buffered (the session's channels are
4596        // closing); either way no panic.
4597        let chunk = [1u8];
4598        let e = aimux_transcription_push_audio(session, chunk.as_ptr(), 1);
4599        aimux_error_free(e);
4600        aimux_transcription_session_drop(session);
4601    }
4602
4603    #[test]
4604    fn transcription_session_drop_is_safe_and_idempotent() {
4605        let model = mock_transcriber_handle();
4606        let session = session(model, 0);
4607        aimux_transcription_session_drop(session);
4608        aimux_transcription_session_drop(session); // idempotent
4609        aimux_transcription_session_drop(0); // safe with 0
4610        aimux_transcription_session_drop(999_999); // safe with unknown
4611
4612        // Operations on a dropped session fail with invalid handle.
4613        assert_eq!(
4614            expect_ffi_error(aimux_transcription_push_audio(session, std::ptr::null(), 0)),
4615            "invalid or expired transcription session handle"
4616        );
4617        assert_eq!(
4618            expect_ffi_error(aimux_transcription_input_done(session)),
4619            "invalid or expired transcription session handle"
4620        );
4621        let e = pull(session, 0).expect_err("dead session");
4622        assert_eq!(
4623            expect_ffi_error(e),
4624            "invalid or expired transcription session handle"
4625        );
4626    }
4627
4628    #[test]
4629    fn transcription_session_drop_unblocks_infinite_next_part() {
4630        let model = mock_transcriber_handle();
4631        let session = session(model, 0);
4632        // Consume StreamStart; with no audio, the next pull waits forever
4633        // unless session_drop aborts the driver and closes the parts stream.
4634        let _ = pull_part(session);
4635
4636        let (done_tx, done_rx) = std::sync::mpsc::channel();
4637        let puller = std::thread::spawn(move || {
4638            if let Err(e) = pull(session, -1) {
4639                aimux_error_free(e);
4640            }
4641            done_tx.send(()).unwrap();
4642        });
4643        std::thread::sleep(Duration::from_millis(50));
4644        assert!(
4645            matches!(
4646                done_rx.try_recv(),
4647                Err(std::sync::mpsc::TryRecvError::Empty)
4648            ),
4649            "next_part(-1) should still be blocked before drop"
4650        );
4651
4652        let started = std::time::Instant::now();
4653        aimux_transcription_session_drop(session);
4654        assert!(
4655            started.elapsed() < Duration::from_secs(2),
4656            "session_drop should not wait for next_part"
4657        );
4658        done_rx
4659            .recv_timeout(Duration::from_secs(2))
4660            .expect("session_drop must wake next_part(-1)");
4661        puller.join().unwrap();
4662        drop_handle(model);
4663    }
4664
4665    #[test]
4666    fn transcription_session_drop_unblocks_backpressured_push_audio() {
4667        let model = mock_transcriber_handle();
4668        let session = session(model, 0);
4669        let sent = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4670        let sent_by_pusher = Arc::clone(&sent);
4671        let (done_tx, done_rx) = std::sync::mpsc::channel();
4672        let pusher = std::thread::spawn(move || {
4673            let chunk = [1u8];
4674            for _ in 0..1024 {
4675                let e = aimux_transcription_push_audio(session, chunk.as_ptr(), chunk.len());
4676                if !e.is_null() {
4677                    aimux_error_free(e);
4678                    break;
4679                }
4680                sent_by_pusher.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4681            }
4682            done_tx.send(()).unwrap();
4683        });
4684
4685        let deadline = std::time::Instant::now() + Duration::from_secs(2);
4686        while sent.load(std::sync::atomic::Ordering::Relaxed) < 64
4687            && std::time::Instant::now() < deadline
4688        {
4689            std::thread::yield_now();
4690        }
4691        assert!(
4692            sent.load(std::sync::atomic::Ordering::Relaxed) >= 64,
4693            "pusher never filled the bounded audio path"
4694        );
4695        std::thread::sleep(Duration::from_millis(50));
4696        assert!(
4697            matches!(
4698                done_rx.try_recv(),
4699                Err(std::sync::mpsc::TryRecvError::Empty)
4700            ),
4701            "pusher unexpectedly completed without backpressure"
4702        );
4703
4704        let started = std::time::Instant::now();
4705        aimux_transcription_session_drop(session);
4706        assert!(
4707            started.elapsed() < Duration::from_secs(2),
4708            "session_drop should not wait for push_audio"
4709        );
4710        done_rx
4711            .recv_timeout(Duration::from_secs(2))
4712            .expect("session_drop must wake a backpressured push_audio");
4713        pusher.join().unwrap();
4714        drop_handle(model);
4715    }
4716
4717    #[test]
4718    fn transcription_session_new_rejects_bad_inputs() {
4719        let model = mock_transcriber_handle();
4720        let mut h = zero_err();
4721        // Bad JSON.
4722        let bad = std::ffi::CString::new("{not json").unwrap();
4723        let e = aimux_transcription_session_new(model, 0, bad.as_ptr(), &mut h);
4724        assert_eq!(h, 0);
4725        assert!(expect_ffi_error(e).starts_with("opts_json: invalid JSON:"));
4726
4727        // Non-transcription model handle.
4728        let lang = mock_handle("mock", "m", "x");
4729        let e = aimux_transcription_session_new(lang, 0, std::ptr::null(), &mut h);
4730        assert_eq!(h, 0);
4731        assert_eq!(
4732            expect_ffi_error(e),
4733            "invalid or expired transcription handle"
4734        );
4735
4736        // Bad abort handle.
4737        let e = aimux_transcription_session_new(model, 999_999, std::ptr::null(), &mut h);
4738        assert_eq!(h, 0);
4739        assert_eq!(expect_ffi_error(e), "invalid or expired abort handle");
4740
4741        // NULL out-params on next_part.
4742        let mut part: *mut c_char = std::ptr::null_mut();
4743        assert_eq!(
4744            expect_ffi_error(aimux_transcription_next_part(
4745                0,
4746                0,
4747                std::ptr::null_mut(),
4748                std::ptr::null_mut()
4749            )),
4750            "out_part: must not be NULL"
4751        );
4752        assert_eq!(
4753            expect_ffi_error(aimux_transcription_next_part(
4754                0,
4755                0,
4756                &mut part,
4757                std::ptr::null_mut()
4758            )),
4759            "out_state: must not be NULL"
4760        );
4761    }
4762}