-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathlib.rs
More file actions
4123 lines (3840 loc) · 161 KB
/
Copy pathlib.rs
File metadata and controls
4123 lines (3840 loc) · 161 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! aimux-ffi: C ABI for multi-language bindings.
//!
//! Provides an opaque handle registry + JSON wire format + push callback
//! stream. The C ABI is consumed by C/C++, Go, Kotlin, Java, Swift, and
//! Flutter. Native bindings (Python / Node) bypass this layer and use
//! `aimux-providers` directly.
//!
//! ## Errors
//!
//! Every fallible C function returns `*mut aimux_error_t`: NULL on
//! success (the result is written to the out-parameter), a heap-owned error
//! value on failure (the out-parameter is left at its sentinel: handle 0,
//! pointer NULL). Every non-NULL error has one code from [`aimux_error_code`]
//! and one message from [`aimux_error_message`], and is released exactly once
//! with [`aimux_error_free`]. Codes 1..13 come from `AiMuxError`, 100..105
//! from `RecordingError`, and 200..206 identify failures detected while
//! crossing the C ABI.
//!
//! ## Memory ownership
//!
//! - Every function returning `*mut c_char` — error getters included —
//! transfers ownership to the caller, who MUST free it with
//! [`aimux_free_string`]. Handles (`u64`) are released with
//! [`aimux_drop_handle`].
//! - [`aimux_stream_text`] callbacks receive `*const c_char` pointers that are
//! valid **only for the duration of the callback**. The callback must copy
//! the data synchronously; the backing buffer is freed when the callback
//! returns.
//!
//! ## Concurrency
//!
//! All async provider work runs on a shared multi-threaded tokio runtime. The
//! C ABI functions are synchronous: they `block_on` the runtime until the
//! operation completes. Callbacks execute on the same thread/call-stack that
//! invoked the FFI function, so they must **not** re-enter the FFI layer:
//! a nested `block_on` on the same thread makes tokio **panic** ("Cannot start
//! a runtime from within a runtime"). Rust's non-unwind `extern "C"` ABI does
//! not allow a panic to propagate — the process terminates (and under this
//! workspace's release profile, `panic = "abort"`, it terminates at the panic
//! site). Either way the re-entrant call must be rejected before it reaches
//! the runtime; the thread-local guard in `ffi_block_on` does that
//! (issue M7).
#![allow(clippy::not_unsafe_ptr_arg_deref)]
// `extern "C"` entry points dereference raw pointers (`*const c_char`) by
// design: the C ABI contract requires callers to pass valid pointers (see
// memory-ownership docs above), so the functions are safe only on the C side.
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_void};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use serde::de::DeserializeOwned;
use aimux_core::AiMuxError;
use aimux_core::generate::{
GenerateTextOptions, generate_object, generate_text, generate_text_as_openai, stream_text,
stream_text_as_openai,
};
use aimux_core::language_model::LanguageModel;
use aimux_core::message::ModelPrompt;
use aimux_core::openai_output::OpenAiStreamOptions;
use aimux_core::provider::Provider;
use aimux_core::recording::RecordingError;
use aimux_core::shared::AbortSignal;
use aimux_core::trace::{RingTraceStore, TraceFilter, TraceLayer};
use aimux_providers::anthropic::{AnthropicConfig, AnthropicProvider};
use aimux_providers::anthropic_aws::{AnthropicAwsProvider, AnthropicAwsProviderConfig};
use aimux_providers::azure::{AzureConfig, AzureProvider};
use aimux_providers::bedrock::{BedrockProvider, BedrockProviderConfig};
use aimux_providers::cohere::{CohereConfig, CohereProvider};
use aimux_providers::google::{GoogleConfig, GoogleProvider};
use aimux_providers::mistral::{MistralConfig, MistralProvider};
use aimux_providers::openai::{OpenAIConfig, OpenAIProvider};
use aimux_providers::tavily::{TavilyConfig, TavilyProvider};
use aimux_providers::vertex::{VertexProvider, VertexProviderConfig};
use aimux_providers::xai::{XAIConfig, XAIProvider};
use aimux_providers::{ProviderOptions, provider, provider_handle};
use futures::StreamExt;
use tokio::runtime::Runtime;
// ─────────────────────────────────────────────────────────────────────────────
// Global state: handle registry + tokio runtime
// ─────────────────────────────────────────────────────────────────────────────
/// A type-erased FFI handle. One registry holds models, providers, sessions
/// and abort signals.
#[derive(Clone)]
enum HandleEntry {
Language(Arc<dyn LanguageModel>),
Provider(Arc<dyn aimux_core::provider::Provider>),
Embedding(Arc<dyn aimux_core::embedding_model::EmbeddingModel>),
Speech(Arc<dyn aimux_core::speech_model::SpeechModel>),
Image(Arc<dyn aimux_core::image_model::ImageModel>),
Transcription(Arc<dyn aimux_core::transcription_model::TranscriptionModel>),
Reranking(Arc<dyn aimux_core::reranking_model::RerankingModel>),
Video(Arc<dyn aimux_core::video_model::VideoModel>),
Search(Arc<dyn aimux_core::search_model::SearchModel>),
Files(Arc<dyn aimux_core::files_model::Files>),
/// Live transcription streaming session (RFC-0028 Phase 2).
TranscriptionSession(Arc<transcription_session::TranscriptionFfiSession>),
Abort(AbortSignal),
}
type Registry = HashMap<u64, HandleEntry>;
static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
fn registry() -> &'static Mutex<Registry> {
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Register a model instance, returning its opaque `u64` handle.
///
/// Handles start at 1; 0 is reserved for "failure / invalid".
fn intern_model(model: Arc<dyn LanguageModel>) -> u64 {
intern_handle(HandleEntry::Language(model))
}
fn intern_handle(h: HandleEntry) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
registry()
.lock()
.expect("aimux-ffi: registry mutex poisoned")
.insert(handle, h);
handle
}
/// Look up any handle.
fn get_handle(handle: u64) -> Option<HandleEntry> {
registry()
.lock()
.expect("aimux-ffi: registry mutex poisoned")
.get(&handle)
.cloned()
}
/// Look up a model by handle, cloning the `Arc` out of the registry.
fn get_model(handle: u64) -> Option<Arc<dyn LanguageModel>> {
match get_handle(handle)? {
HandleEntry::Language(m) => Some(m),
_ => None,
}
}
/// A model handle, or [`FfiError::InvalidHandle`] (`"model"`).
fn model_of(handle: u64) -> Result<Arc<dyn LanguageModel>, FfiError> {
get_model(handle).ok_or(FfiError::InvalidHandle { expected: "model" })
}
/// A live handle of any registered type, or [`FfiError::InvalidHandle`] naming what was
/// expected. Callers match the arm they need and fall back to the same error.
fn entry_of(handle: u64, expected: &'static str) -> Result<HandleEntry, FfiError> {
get_handle(handle).ok_or(FfiError::InvalidHandle { expected })
}
fn get_abort_signal(handle: u64) -> Option<AbortSignal> {
match get_handle(handle)? {
HandleEntry::Abort(signal) => Some(signal),
_ => None,
}
}
fn abort_of(handle: u64) -> Result<AbortSignal, FfiError> {
get_abort_signal(handle).ok_or(FfiError::InvalidHandle { expected: "abort" })
}
/// Remove a handle from the registry (the model drops when the last ref goes).
/// Trace stores bound to the handle are released with it.
fn drop_handle(handle: u64) {
let removed = registry()
.lock()
.expect("aimux-ffi: registry mutex poisoned")
.remove(&handle);
// A transcription session owns a driver task: abort and join it here too,
// so the generic drop is never a silent leak (the registry mutex is
// already released — the join must not hold it).
if let Some(HandleEntry::TranscriptionSession(s)) = removed {
s.terminate();
}
if let Some(stores) = TRACE_STORES.get() {
stores
.lock()
.expect("aimux-ffi: trace registry mutex poisoned")
.remove(&handle);
}
}
fn drop_abort_signal(handle: u64) {
let mut registry = registry()
.lock()
.expect("aimux-ffi: registry mutex poisoned");
if matches!(registry.get(&handle), Some(HandleEntry::Abort(_))) {
registry.remove(&handle);
}
}
/// Transcription streaming sessions (RFC-0028 Phase 2).
mod transcription_session;
/// The shared tokio runtime driving all async provider calls.
fn runtime() -> &'static Runtime {
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| {
tokio::runtime::Runtime::new().expect("aimux-ffi: failed to build tokio runtime")
})
}
thread_local! {
/// Re-entrancy guard: set while an FFI entry point is `block_on`-ing the
/// shared runtime on the current thread.
///
/// Stream callbacks (`on_part`/`on_done`) run synchronously on
/// the same thread/call-stack that entered the FFI function, so a callback
/// that calls back into the FFI layer would enter a second `block_on` on
/// this thread. tokio rejects nested `block_on` with a **panic**; Rust's
/// non-unwind `extern "C"` ABI terminates the process rather than letting
/// the panic propagate (and `panic = "abort"` terminates at the panic site).
/// `ffi_block_on` checks this guard and turns that re-entrant call into
/// a [`FfiError::ReentrantCall`] instead (issue M7).
static IN_FFI_BLOCK_ON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Run a future on the shared runtime from an FFI entry point (issue M7).
///
/// Rejects re-entrant calls made from inside a stream callback, returning
/// [`FfiError::ReentrantCall`] instead of letting tokio's nested `block_on` panic —
/// a non-unwind `extern "C"` call never lets the panic propagate, so the
/// process would terminate. The guard is released when the future completes,
/// including when it panics.
fn ffi_block_on<F, T>(f: F) -> Result<T, FfiError>
where
F: std::future::Future<Output = T>,
{
IN_FFI_BLOCK_ON.with(|flag| {
if flag.replace(true) {
return Err(FfiError::ReentrantCall);
}
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
IN_FFI_BLOCK_ON.with(|flag| flag.set(false));
}
}
let _reset = Reset;
Ok(runtime().block_on(f))
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Error model (aimux-error.h)
// ─────────────────────────────────────────────────────────────────────────────
/// A failure detected while lifting arguments, using handles, invoking a
/// callback, or lowering a result across the C ABI.
#[derive(Debug, Clone)]
enum FfiError {
/// A required pointer argument was NULL.
NullPointer { argument: &'static str },
/// A string argument was not valid UTF-8.
InvalidUtf8 { argument: &'static str },
/// A JSON-text argument of this wire format (`prompt_json`, `opts_json`,
/// `config_json`, …) did not parse. Not `AiMuxError::JsonParse` — that
/// one is about provider responses.
InvalidWireJson {
argument: &'static str,
message: String,
},
/// A handle argument is 0, released, or has the wrong handle type.
InvalidHandle { expected: &'static str },
/// The FFI was re-entered from inside one of its own callbacks.
ReentrantCall,
/// A result could not be serialized to the wire format.
ResultSerialization { message: String },
/// A host callback panicked (caught inside the C ABI).
CallbackFailure { message: String },
}
impl std::fmt::Display for FfiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FfiError::NullPointer { argument } => write!(f, "{argument}: must not be NULL"),
FfiError::InvalidUtf8 { argument } => write!(f, "{argument}: must be valid UTF-8"),
FfiError::InvalidWireJson { argument, message } => {
write!(f, "{argument}: invalid JSON: {message}")
}
FfiError::InvalidHandle { expected } => {
write!(f, "invalid or expired {expected} handle")
}
FfiError::ReentrantCall => {
f.write_str("re-entrant FFI call from within a callback is not allowed")
}
FfiError::ResultSerialization { message } => {
write!(f, "could not serialize result: {message}")
}
FfiError::CallbackFailure { message } => write!(f, "host callback failed: {message}"),
}
}
}
/// The errors that can cross the Aimux C ABI.
enum AiMuxFfiError {
AiMux(AiMuxError),
Recording(RecordingError),
Ffi(FfiError),
}
impl From<AiMuxError> for AiMuxFfiError {
fn from(inner: AiMuxError) -> Self {
AiMuxFfiError::AiMux(inner)
}
}
impl From<RecordingError> for AiMuxFfiError {
fn from(inner: RecordingError) -> Self {
AiMuxFfiError::Recording(inner)
}
}
impl From<FfiError> for AiMuxFfiError {
fn from(e: FfiError) -> Self {
AiMuxFfiError::Ffi(e)
}
}
impl std::fmt::Display for AiMuxFfiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AiMuxFfiError::AiMux(e) => e.fmt(f),
AiMuxFfiError::Recording(e) => e.fmt(f),
AiMuxFfiError::Ffi(e) => e.fmt(f),
}
}
}
/// The owned error returned by a failed C ABI invocation. Opaque to C: read it
/// with the `aimux_error_*` getters, then release it once with
/// `aimux_error_free`.
#[allow(non_camel_case_types)]
pub struct aimux_error_t {
error: AiMuxFfiError,
}
type FfiResult<T> = Result<T, AiMuxFfiError>;
/// Ok → run `write` with the value and return NULL; Err → hand the owned
/// error to the caller.
fn finish<T>(r: FfiResult<T>, write: impl FnOnce(T)) -> *mut aimux_error_t {
match r {
Ok(v) => {
write(v);
std::ptr::null_mut()
}
Err(error) => Box::into_raw(Box::new(aimux_error_t { error })),
}
}
/// Entry-point shape: `uint64_t *out_handle`. NULL out-param is a
/// `NullPointer("out_handle")`; the sentinel 0 is written before `f` runs.
fn with_out_handle(out_handle: *mut u64, f: impl FnOnce() -> FfiResult<u64>) -> *mut aimux_error_t {
if out_handle.is_null() {
return finish(
Err(FfiError::NullPointer {
argument: "out_handle",
}
.into()),
|_: u64| {},
);
}
unsafe { *out_handle = 0 };
finish(f(), |h| unsafe { *out_handle = h })
}
/// Entry-point shape: `char **out_json` (name given by `argument`). NULL
/// out-param is a `NullPointer`; the sentinel NULL is written before `f` runs.
fn with_out_string(
out: *mut *mut c_char,
argument: &'static str,
f: impl FnOnce() -> FfiResult<String>,
) -> *mut aimux_error_t {
if out.is_null() {
return finish(
Err(FfiError::NullPointer { argument }.into()),
|_: String| {},
);
}
unsafe { *out = std::ptr::null_mut() };
finish(f(), |s| unsafe { *out = into_cstring_raw(s) })
}
/// Entry-point shape: no result.
fn no_result(f: impl FnOnce() -> FfiResult<()>) -> *mut aimux_error_t {
finish(f(), |()| {})
}
// ── aimux_error_* ────────────────────────────────────────────────────────────
/// Release a returned error. NULL-safe; call exactly once.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_free(err: *mut aimux_error_t) {
if !err.is_null() {
// SAFETY: `err` came from `Box::into_raw` in `finish` and the caller
// passes it exactly once.
drop(unsafe { Box::from_raw(err) });
}
}
// ── Unified error codes (aimux-error.h `aimux_error_code_t`) ─────────────────
pub const AIMUX_OK: i32 = 0;
// 1 is the catch-all `Other` (it was `AIMUX_E_UNKNOWN`, whose slot it inherits:
// a code a binding does not know is a header mismatch, not a user-facing code).
pub const AIMUX_E_OTHER: i32 = 1;
pub const AIMUX_E_JSON_PARSE: i32 = 2;
pub const AIMUX_E_INVALID_RESPONSE_DATA: i32 = 3;
pub const AIMUX_E_TOOL: i32 = 4;
pub const AIMUX_E_INVALID_ARGUMENT: i32 = 5;
pub const AIMUX_E_INVALID_PROMPT: i32 = 6;
pub const AIMUX_E_TOKEN_EXPIRED: i32 = 7;
pub const AIMUX_E_UNSUPPORTED_FUNCTIONALITY: i32 = 8;
pub const AIMUX_E_NO_SUCH_MODEL: i32 = 9;
pub const AIMUX_E_NO_SUCH_PROVIDER: i32 = 10;
pub const AIMUX_E_API_CALL: i32 = 11;
pub const AIMUX_E_TIMEOUT: i32 = 12;
pub const AIMUX_E_ABORTED: i32 = 13;
// 100..105 preserve `RecordingError` as a separate high-level type while C
// uses one code space for every returned error.
pub const AIMUX_E_RECORDING_INIT: i32 = 100;
pub const AIMUX_E_RECORDING_OPEN_FILE: i32 = 101;
pub const AIMUX_E_RECORDING_SPAWN: i32 = 102;
pub const AIMUX_E_RECORDING_WRITER_GONE: i32 = 103;
pub const AIMUX_E_RECORDING_FLUSH_TIMEOUT: i32 = 104;
pub const AIMUX_E_RECORDING_WRITE: i32 = 105;
// 200..206 are failures detected while lifting arguments, looking up handles,
// invoking callbacks, or lowering a result across the C ABI.
pub const AIMUX_E_FFI_NULL_POINTER: i32 = 200;
pub const AIMUX_E_FFI_INVALID_UTF8: i32 = 201;
pub const AIMUX_E_FFI_INVALID_WIRE_JSON: i32 = 202;
pub const AIMUX_E_FFI_INVALID_HANDLE: i32 = 203;
pub const AIMUX_E_FFI_REENTRANT_CALL: i32 = 204;
pub const AIMUX_E_FFI_RESULT_SERIALIZATION: i32 = 205;
pub const AIMUX_E_FFI_CALLBACK_FAILURE: i32 = 206;
// ── `aimux_transcription_next_part_state_t` ──────────────────────────────────
pub const AIMUX_TRANSCRIPTION_NEXT_PART_PART: i32 = 1;
pub const AIMUX_TRANSCRIPTION_NEXT_PART_ENDED: i32 = 2;
pub const AIMUX_TRANSCRIPTION_NEXT_PART_TIMEOUT: i32 = 3;
fn aimux_error_code_of(err: &AiMuxError) -> i32 {
match err {
AiMuxError::ApiCall { .. } => AIMUX_E_API_CALL,
AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE,
AiMuxError::InvalidResponseData(_) => AIMUX_E_INVALID_RESPONSE_DATA,
AiMuxError::Tool(_) => AIMUX_E_TOOL,
AiMuxError::InvalidArgument(_) => AIMUX_E_INVALID_ARGUMENT,
AiMuxError::InvalidPrompt(_) => AIMUX_E_INVALID_PROMPT,
AiMuxError::TokenExpired(_) => AIMUX_E_TOKEN_EXPIRED,
AiMuxError::UnsupportedFunctionality(_) => AIMUX_E_UNSUPPORTED_FUNCTIONALITY,
AiMuxError::NoSuchModel { .. } => AIMUX_E_NO_SUCH_MODEL,
AiMuxError::NoSuchProvider { .. } => AIMUX_E_NO_SUCH_PROVIDER,
AiMuxError::Timeout(_) => AIMUX_E_TIMEOUT,
AiMuxError::Aborted => AIMUX_E_ABORTED,
AiMuxError::Other(_) => AIMUX_E_OTHER,
}
}
fn recording_error_code_of(e: &RecordingError) -> i32 {
use RecordingError as R;
match e {
R::Init { .. } => AIMUX_E_RECORDING_INIT,
R::OpenFile { .. } => AIMUX_E_RECORDING_OPEN_FILE,
R::Spawn { .. } => AIMUX_E_RECORDING_SPAWN,
R::WriterGone => AIMUX_E_RECORDING_WRITER_GONE,
R::FlushTimeout => AIMUX_E_RECORDING_FLUSH_TIMEOUT,
R::Write(_) => AIMUX_E_RECORDING_WRITE,
}
}
fn ffi_error_code_of(e: &FfiError) -> i32 {
match e {
FfiError::NullPointer { .. } => AIMUX_E_FFI_NULL_POINTER,
FfiError::InvalidUtf8 { .. } => AIMUX_E_FFI_INVALID_UTF8,
FfiError::InvalidWireJson { .. } => AIMUX_E_FFI_INVALID_WIRE_JSON,
FfiError::InvalidHandle { .. } => AIMUX_E_FFI_INVALID_HANDLE,
FfiError::ReentrantCall => AIMUX_E_FFI_REENTRANT_CALL,
FfiError::ResultSerialization { .. } => AIMUX_E_FFI_RESULT_SERIALIZATION,
FfiError::CallbackFailure { .. } => AIMUX_E_FFI_CALLBACK_FAILURE,
}
}
fn error_code_of(e: &AiMuxFfiError) -> i32 {
match e {
AiMuxFfiError::AiMux(e) => aimux_error_code_of(e),
AiMuxFfiError::Recording(e) => recording_error_code_of(e),
AiMuxFfiError::Ffi(e) => ffi_error_code_of(e),
}
}
fn api_call(e: &AiMuxError) -> Option<&aimux_core::ApiCallError> {
match e {
AiMuxError::ApiCall(d) => Some(d),
_ => None,
}
}
/// Apply `f` to the `AiMuxError` stored in a returned error.
///
/// The closure keeps the borrowed error scoped to this call instead of
/// manufacturing an unconstrained lifetime from the raw pointer.
fn map_aimux_error<T>(err: *const aimux_error_t, f: impl FnOnce(&AiMuxError) -> T) -> Option<T> {
// SAFETY: the C API requires NULL or a live error returned by `finish`.
match unsafe { err.as_ref() } {
Some(aimux_error_t {
error: AiMuxFfiError::AiMux(e),
}) => Some(f(e)),
_ => None,
}
}
fn opt_cstring(v: Option<String>) -> *mut c_char {
v.map_or(std::ptr::null_mut(), into_cstring_raw)
}
// ── aimux_error_* getters ────────────────────────────────────────────
//
// One getter per fact. `code` and `message` answer for every returned error;
// the rest belong to one AiMuxError code and return NULL / -1 / 0 under every
// other code or for NULL. Strings are owned by the caller (aimux_free_string).
/// Machine-readable code (`aimux_error_code_t`); `AIMUX_OK` for NULL.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_code(err: *const aimux_error_t) -> i32 {
// SAFETY: NULL or a live error returned by `finish`.
unsafe { err.as_ref() }.map_or(AIMUX_OK, |e| error_code_of(&e.error))
}
/// Human-readable description for every code; NULL only for NULL.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_message(err: *const aimux_error_t) -> *mut c_char {
// SAFETY: NULL or a live error returned by `finish`.
unsafe { err.as_ref() }.map_or(std::ptr::null_mut(), |e| {
into_cstring_raw(e.error.to_string())
})
}
/// HTTP status: the observed status under `AIMUX_E_API_CALL`, 401 by
/// definition under `AIMUX_E_TOKEN_EXPIRED`, -1 otherwise or when no response
/// was observed (`AiMuxError::status_code`).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_status(err: *const aimux_error_t) -> i32 {
map_aimux_error(err, AiMuxError::status_code)
.flatten()
.map_or(-1, i32::from)
}
/// `AIMUX_E_API_CALL`: retry hint in ms (0 = retry now), or -1 when none.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_retry_ms(err: *const aimux_error_t) -> i64 {
map_aimux_error(err, AiMuxError::retry_after_hint)
.flatten()
.unwrap_or(-1)
}
/// 1 when retrying may help, 0 when it will not — the core's verdict. Do not
/// infer it from `status`: a statusless `AIMUX_E_API_CALL` may be either.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_retryable(err: *const aimux_error_t) -> i32 {
map_aimux_error(err, AiMuxError::is_retryable).unwrap_or(false) as i32
}
/// `AIMUX_E_API_CALL`: the provider's own error code, e.g. "insufficient_quota".
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_provider_code(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(map_aimux_error(err, |e| api_call(e)?.provider_code.clone()).flatten())
}
/// `AIMUX_E_API_CALL`: the failure's own text ("slow down"), without the
/// composed prefix `message` carries ("API call error: HTTP 429: slow down").
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_provider_message(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
map_aimux_error(err, |e| Some(api_call(e)?.message.clone()))
.flatten()
.filter(|m| !m.is_empty()),
)
}
/// `AIMUX_E_API_CALL`: provider request id.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_request_id(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(map_aimux_error(err, |e| api_call(e)?.request_id.clone()).flatten())
}
/// `AIMUX_E_API_CALL`: raw response body.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_response_body(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(map_aimux_error(err, |e| api_call(e)?.response_body.clone()).flatten())
}
/// `AIMUX_E_NO_SUCH_MODEL`: the model id that was asked for.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_model_id(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
map_aimux_error(err, |e| match e {
AiMuxError::NoSuchModel { model_id, .. } => Some(model_id.clone()),
_ => None,
})
.flatten(),
)
}
/// `AIMUX_E_NO_SUCH_MODEL`: the model type it was asked for as.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_model_type(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
map_aimux_error(err, |e| match e {
AiMuxError::NoSuchModel { model_type, .. } => Some(model_type.clone()),
_ => None,
})
.flatten(),
)
}
/// `AIMUX_E_NO_SUCH_PROVIDER`: the provider id that was asked for.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_error_provider_id(err: *const aimux_error_t) -> *mut c_char {
opt_cstring(
map_aimux_error(err, |e| match e {
AiMuxError::NoSuchProvider { provider_id } => Some(provider_id.clone()),
_ => None,
})
.flatten(),
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Argument helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Read a required string argument, naming it in the failure
/// (`NullPointer` / `InvalidUtf8`).
///
/// # Safety: `ptr` is null or a valid NUL-terminated C string.
fn str_arg(ptr: *const c_char, argument: &'static str) -> Result<String, FfiError> {
if ptr.is_null() {
return Err(FfiError::NullPointer { argument });
}
// SAFETY: caller guarantees `ptr` is a valid NUL-terminated C string.
unsafe { CStr::from_ptr(ptr) }
.to_str()
.map(str::to_owned)
.map_err(|_| FfiError::InvalidUtf8 { argument })
}
/// Read an optional string argument: NULL means "absent"; a non-NULL pointer
/// must be valid UTF-8 or it is [`FfiError::InvalidUtf8`] — absent and
/// malformed are different things.
fn opt_str_arg(ptr: *const c_char, argument: &'static str) -> Result<Option<String>, FfiError> {
if ptr.is_null() {
return Ok(None);
}
str_arg(ptr, argument).map(Some)
}
/// Build (a, b) from two required C strings.
fn parse_two_args(
a: *const c_char,
an: &'static str,
b: *const c_char,
bn: &'static str,
) -> Result<(String, String), FfiError> {
Ok((str_arg(a, an)?, str_arg(b, bn)?))
}
/// Parse four required C string arguments; any null fails the whole call.
#[allow(clippy::too_many_arguments)]
fn parse_four_args(
a: *const c_char,
an: &'static str,
b: *const c_char,
bn: &'static str,
c: *const c_char,
cn: &'static str,
d: *const c_char,
dn: &'static str,
) -> Result<(String, String, String, String), FfiError> {
Ok((
str_arg(a, an)?,
str_arg(b, bn)?,
str_arg(c, cn)?,
str_arg(d, dn)?,
))
}
/// Parse the base_url argument; NULL or an empty string means unset.
fn parse_base_url(base_url: *const c_char) -> Result<Option<String>, FfiError> {
Ok(opt_str_arg(base_url, "base_url")?.filter(|url| !url.is_empty()))
}
/// Where a serde failure on a wire-JSON argument belongs. Malformed text
/// (syntax / EOF) is this layer's finding: [`FfiError::InvalidWireJson`].
/// Well-formed JSON of the wrong shape (missing field, wrong type) is what the
/// core would reject: [`AiMuxError::InvalidArgument`]. `detail` is the
/// message to carry (usually `e.to_string()`, sometimes with a line prefix).
fn wire_failure(argument: &'static str, e: &serde_json::Error, detail: String) -> AiMuxFfiError {
match e.classify() {
serde_json::error::Category::Data => {
AiMuxError::InvalidArgument(format!("{argument}: {detail}")).into()
}
_ => FfiError::InvalidWireJson {
argument,
message: detail,
}
.into(),
}
}
/// [`wire_failure`] with the serde message as the detail.
fn wire_err(argument: &'static str, e: serde_json::Error) -> AiMuxFfiError {
let detail = e.to_string();
wire_failure(argument, &e, detail)
}
/// Parse a JSON C-string argument into `T`. NULL / non-UTF-8 → FFI
/// `NullPointer` / `InvalidUtf8`; text that does not parse → FFI
/// `InvalidWireJson`; text that parses but violates `T`'s schema →
/// `AiMuxError::InvalidArgument` (see [`wire_failure`]).
fn parse_json_arg<T: DeserializeOwned>(json: *const c_char, name: &'static str) -> FfiResult<T> {
let s = str_arg(json, name)?;
serde_json::from_str::<T>(&s).map_err(|e| wire_err(name, e))
}
/// Parse the prompt JSON accepted by the FFI (`prompt_json`, required).
///
/// Accepts either a bare prompt value (`"text"` or `[{...}]`) or a wrapper
/// object `{"prompt": <value>}`.
fn parse_prompt_arg(prompt_json: *const c_char) -> FfiResult<ModelPrompt> {
let s = str_arg(prompt_json, "prompt_json")?;
let parse = |json: &str| -> Result<ModelPrompt, serde_json::Error> {
let value: serde_json::Value = serde_json::from_str(json)?;
let inner = match &value {
serde_json::Value::Object(obj) if obj.len() == 1 && obj.contains_key("prompt") => {
obj.get("prompt").expect("checked by guard")
}
_ => &value,
};
serde_json::from_value(inner.clone())
};
parse(&s).map_err(|e| wire_err("prompt_json", e))
}
/// Parse the options JSON (`opts_json`, optional). NULL / empty / `null`
/// yields the default options.
fn parse_opts_arg(opts_json: *const c_char) -> FfiResult<GenerateTextOptions> {
match opt_str_arg(opts_json, "opts_json")? {
Some(s) if !s.trim().is_empty() && s.trim() != "null" => {
serde_json::from_str(&s).map_err(|e| wire_err("opts_json", e))
}
_ => Ok(GenerateTextOptions::default()),
}
}
/// Parse the optional `config_json` argument (`ProviderOptions`); NULL,
/// empty or "null" means unset.
fn parse_provider_options(config_json: *const c_char) -> FfiResult<Option<ProviderOptions>> {
match opt_str_arg(config_json, "config_json")? {
Some(s) if !s.trim().is_empty() && s.trim() != "null" => {
serde_json::from_str::<ProviderOptions>(&s)
.map(Some)
.map_err(|e| wire_err("config_json", e))
}
_ => Ok(None),
}
}
/// Read a config-style JSON C string and normalize it for lenient
/// deserialization: NULL, empty, whitespace-only and `"null"` all become
/// `"{}"` (defaults). Invalid UTF-8 is [`FfiError::InvalidUtf8`].
fn normalize_config_json(json: *const c_char, argument: &'static str) -> Result<String, FfiError> {
Ok(match opt_str_arg(json, argument)? {
Some(s) if !s.trim().is_empty() && s.trim() != "null" => s,
_ => String::from("{}"),
})
}
/// Build an owned C string (`*mut c_char`) from a `String`, transferring
/// ownership to the caller (who must free it with [`aimux_free_string`]).
///
/// Never returns null (issue M1): interior NUL bytes — which would make
/// `CString::new` fail — are replaced with U+FFFD so the C-side contract
/// ("a non-null NUL-terminated buffer to free, or null") always holds. The
/// replacement is reported through `tracing` (RFC-0014 logging, which C hosts
/// route via `aimux_init_logging`) so accidental NULs stay visible without
/// the library writing to the host's stderr directly.
fn into_cstring_raw(s: String) -> *mut c_char {
if s.contains('\0') {
tracing::warn!(
target: "aimux_ffi",
nul_bytes = s.bytes().filter(|&b| b == 0).count(),
"string contains NUL byte(s); replaced with U+FFFD before FFI return"
);
}
let sanitized = s.replace('\0', "\u{FFFD}");
// No interior NUL remains: this cannot fail.
CString::new(sanitized)
.expect("aimux-ffi: impossible: NUL-free string rejected by CString::new")
.into_raw()
}
/// Serialize a result to its wire JSON, or [`FfiError::ResultSerialization`].
fn to_json<T: serde::Serialize>(v: &T) -> FfiResult<String> {
serde_json::to_string(v).map_err(|e| {
FfiError::ResultSerialization {
message: format!("serialize: {e}"),
}
.into()
})
}
/// Run an async model operation on the runtime and serialize its result.
fn run_json<F, T>(f: F) -> FfiResult<String>
where
F: std::future::Future<Output = Result<T, AiMuxError>>,
T: serde::Serialize,
{
let v = ffi_block_on(f)??;
to_json(&v)
}
/// Invoke a stream callback (`on_part`/`on_done`) while catching any panic.
///
/// The callbacks are declared `extern "C-unwind"`, so a *Rust* panic raised
/// inside a Rust-implemented callback (panic=unwind builds, same runtime)
/// propagates back here instead of aborting at the ABI edge (issue #64); this
/// wrapper catches it and converts it to a structured
/// [`FfiError::CallbackFailure`] that ends the stream. Foreign exceptions
/// (C++, JVM, Swift, Dart, Go) are NOT covered — `catch_unwind` may abort on
/// them — so callbacks must not unwind across the C ABI; binding trampolines
/// catch their own language's exceptions. (Release builds use `panic =
/// "abort"`, in which case the
/// process aborts before this point and the code is never produced).
///
/// `AssertUnwindSafe` is required because the callback receives raw pointers
/// (`*const c_char`/`*mut c_void`) that are not `UnwindSafe`; this is sound
/// because we abort the stream on any panic rather than continuing.
fn invoke_stream_callback(callback_name: &str, f: impl FnOnce()) -> Result<(), FfiError> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(()) => Ok(()),
Err(payload) => {
let msg = payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic>");
Err(FfiError::CallbackFailure {
message: format!("stream callback '{callback_name}' panicked: {msg}"),
})
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// C ABI: provider constructors
// ─────────────────────────────────────────────────────────────────────────────
/// Create an OpenAI model instance. AiMuxError: invalid model id.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_openai_new(
api_key: *const c_char,
model_id: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
let m = OpenAIProvider::new(OpenAIConfig::new(api_key)).language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an OpenAI model instance with a custom base URL.
///
/// `base_url` may be null (defaults to the provider's standard URL).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_openai_new_with_base(
api_key: *const c_char,
model_id: *const c_char,
base_url: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
let mut config = OpenAIConfig::new(api_key);
if let Some(url) = parse_base_url(base_url)? {
config = config.with_base_url(url);
}
let m = OpenAIProvider::new(config).language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an Anthropic model instance. AiMuxError: invalid model id.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_anthropic_new(
api_key: *const c_char,
model_id: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
let m = AnthropicProvider::new(AnthropicConfig::new(api_key)).language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an Anthropic model instance with a custom base URL.
///
/// `base_url` may be null (defaults to the provider's standard URL).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_anthropic_new_with_base(
api_key: *const c_char,
model_id: *const c_char,
base_url: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let (api_key, model_id) = parse_two_args(api_key, "api_key", model_id, "model_id")?;
let mut config = AnthropicConfig::new(api_key);
if let Some(url) = parse_base_url(base_url)? {
config = config.with_base_url(url);
}
let m = AnthropicProvider::new(config).language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an Anthropic-on-AWS model instance (API key + region).
#[unsafe(no_mangle)]
pub extern "C" fn aimux_anthropic_aws_new(
api_key: *const c_char,
region: *const c_char,
model_id: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
let region = str_arg(region, "region")?;
let model_id = str_arg(model_id, "model_id")?;
let m =
AnthropicAwsProvider::new(AnthropicAwsProviderConfig::with_api_key(api_key, region))
.language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an Anthropic-on-AWS model instance with a custom base URL.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_anthropic_aws_new_with_base(
api_key: *const c_char,
region: *const c_char,
model_id: *const c_char,
base_url: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
let region = str_arg(region, "region")?;
let model_id = str_arg(model_id, "model_id")?;
let mut config = AnthropicAwsProviderConfig::with_api_key(api_key, region);
if let Some(url) = parse_base_url(base_url)? {
config = config.with_base_url(url);
}
let m = AnthropicAwsProvider::new(config).language_model(&model_id)?;
Ok(intern_model(Arc::from(m)))
})
}
/// Create an Azure OpenAI model instance (API key + resource name).
///
/// `api_version` may be null (uses the provider default). The deployment
/// name is passed as `model_id`.
#[unsafe(no_mangle)]
pub extern "C" fn aimux_azure_new(
api_key: *const c_char,
resource_name: *const c_char,
deployment: *const c_char,
api_version: *const c_char,
out_handle: *mut u64,
) -> *mut aimux_error_t {
with_out_handle(out_handle, || {
let api_key = str_arg(api_key, "api_key")?;
let resource_name = str_arg(resource_name, "resource_name")?;
let deployment = str_arg(deployment, "deployment")?;
let mut config = AzureConfig::new()
.with_api_key(api_key)
.with_resource_name(resource_name);
if let Some(v) = opt_str_arg(api_version, "api_version")?.filter(|v| !v.is_empty()) {
config = config.with_api_version(v);