1#![allow(clippy::not_unsafe_ptr_arg_deref)]
44use 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#[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 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
122fn 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
138fn get_handle(handle: u64) -> Option<HandleEntry> {
140 registry()
141 .lock()
142 .expect("aimux-ffi: registry mutex poisoned")
143 .get(&handle)
144 .cloned()
145}
146
147fn get_model(handle: u64) -> Option<Arc<dyn LanguageModel>> {
149 match get_handle(handle)? {
150 HandleEntry::Language(m) => Some(m),
151 _ => None,
152 }
153}
154
155fn model_of(handle: u64) -> Result<Arc<dyn LanguageModel>, FfiError> {
157 get_model(handle).ok_or(FfiError::InvalidHandle { expected: "model" })
158}
159
160fn 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
177fn drop_handle(handle: u64) {
180 let removed = registry()
181 .lock()
182 .expect("aimux-ffi: registry mutex poisoned")
183 .remove(&handle);
184 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
207mod transcription_session;
209
210fn 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 static IN_FFI_BLOCK_ON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
231}
232
233fn 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#[derive(Debug, Clone)]
266enum FfiError {
267 NullPointer { argument: &'static str },
269 InvalidUtf8 { argument: &'static str },
271 InvalidWireJson {
275 argument: &'static str,
276 message: String,
277 },
278 InvalidHandle { expected: &'static str },
280 ReentrantCall,
282 ResultSerialization { message: String },
284 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
310enum 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#[allow(non_camel_case_types)]
347pub struct aimux_error_t {
348 error: AiMuxFfiError,
349}
350
351type FfiResult<T> = Result<T, AiMuxFfiError>;
352
353fn 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
365fn 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
381fn 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
398fn no_result(f: impl FnOnce() -> FfiResult<()>) -> *mut aimux_error_t {
400 finish(f(), |()| {})
401}
402
403#[unsafe(no_mangle)]
407pub extern "C" fn aimux_error_free(err: *mut aimux_error_t) {
408 if !err.is_null() {
409 drop(unsafe { Box::from_raw(err) });
412 }
413}
414
415pub const AIMUX_OK: i32 = 0;
418pub const AIMUX_E_OTHER: i32 = 1;
421pub const AIMUX_E_JSON_PARSE: i32 = 2;
422pub const AIMUX_E_INVALID_RESPONSE_DATA: i32 = 3;
423pub 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;
435pub 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
442pub 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
451pub 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
461pub 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
533fn map_aimux_error<T>(err: *const aimux_error_t, f: impl FnOnce(&AiMuxError) -> T) -> Option<T> {
538 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#[unsafe(no_mangle)]
559pub extern "C" fn aimux_error_code(err: *const aimux_error_t) -> i32 {
560 unsafe { err.as_ref() }.map_or(AIMUX_OK, |e| error_code_of(&e.error))
562}
563
564#[unsafe(no_mangle)]
566pub extern "C" fn aimux_error_message(err: *const aimux_error_t) -> *mut c_char {
567 unsafe { err.as_ref() }.map_or(std::ptr::null_mut(), |e| {
569 into_cstring_raw(e.error.to_string())
570 })
571}
572
573#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
812fn 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 unsafe { CStr::from_ptr(ptr) }
826 .to_str()
827 .map(str::to_owned)
828 .map_err(|_| FfiError::InvalidUtf8 { argument })
829}
830
831fn 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
841fn 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#[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
871fn 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
876fn 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
894fn wire_err(argument: &'static str, e: serde_json::Error) -> AiMuxFfiError {
896 let detail = e.to_string();
897 wire_failure(argument, &e, detail)
898}
899
900fn 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
909fn 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
928fn 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
939fn 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
952fn 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
962fn 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 CString::new(sanitized)
982 .expect("aimux-ffi: impossible: NUL-free string rejected by CString::new")
983 .into_raw()
984}
985
986fn 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
996fn 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
1006fn 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[unsafe(no_mangle)]
1674pub extern "C" fn aimux_abort_signal_new() -> u64 {
1675 intern_handle(HandleEntry::Abort(AbortSignal::new()))
1676}
1677
1678#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
1941fn 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 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 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 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 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 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 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#[unsafe(no_mangle)]
2130pub extern "C" fn aimux_drop_handle(handle: u64) {
2131 if handle != 0 {
2132 drop_handle(handle);
2133 }
2134}
2135
2136#[unsafe(no_mangle)]
2145pub unsafe extern "C" fn aimux_free_string(ptr: *mut c_char) {
2146 if ptr.is_null() {
2147 return;
2148 }
2149 drop(unsafe { CString::from_raw(ptr) });
2151}
2152
2153#[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#[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#[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#[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#[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#[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
2493fn 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#[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 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#[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(()); }
2573 return Err(FfiError::NullPointer { argument: "data" }.into());
2574 }
2575 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#[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#[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 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 Ok(Some(Ok(part))) => (AIMUX_TRANSCRIPTION_NEXT_PART_PART, Some(to_json(&part)?)),
2643 Ok(None) => (AIMUX_TRANSCRIPTION_NEXT_PART_ENDED, None),
2645 Err(AiMuxError::Timeout(_)) => (AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None),
2647 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#[unsafe(no_mangle)]
2662pub extern "C" fn aimux_transcription_session_drop(session: u64) {
2663 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
2991static 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[unsafe(no_mangle)]
3185pub extern "C" fn aimux_recording_stop() {
3186 aimux_core::recording::init_recording(None);
3187}
3188
3189#[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#[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#[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 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 AiMuxError::JsonParse(m) => AiMuxError::InvalidArgument(format!("config_json: {m}")),
3237 e => e,
3238 })?;
3239 Ok(())
3240 })
3241}
3242
3243#[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 let _ = aimux_provider_utils::init_proxy(config);
3259 Ok(())
3260 })
3261}
3262
3263#[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
3297fn 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 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#[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 return Err(FfiError::InvalidHandle {
3349 expected: "router child",
3350 }
3351 .into());
3352 }
3353 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 _ => 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#[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 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#[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 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 fn boxed(p: impl Into<AiMuxFfiError>) -> *mut aimux_error_t {
3452 finish::<()>(Err(p.into()), |()| {})
3453 }
3454
3455 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 #[test]
3709 fn unified_error_codes_cover_every_source() {
3710 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 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 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 assert_eq!(aimux_error_code(std::ptr::null()), AIMUX_OK);
3743 assert!(aimux_error_message(std::ptr::null()).is_null());
3744
3745 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 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 #[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 #[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 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 #[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 #[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 #[test]
3956 fn stream_callback_ok_passthrough() {
3957 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 let _ = pull_part(session);
4568 assert_eq!(
4569 pull(session, 50).ok(),
4570 Some((AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT, None))
4571 );
4572
4573 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 let _ = pull_part(session);
4589
4590 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 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); aimux_transcription_session_drop(0); aimux_transcription_session_drop(999_999); 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 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 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 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 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 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}