Skip to content

Commit d6ada6c

Browse files
refactor(ffi)!: one opaque error pointer and one unified code space (RFC-0030)
Every fallible C function now returns `aimux_error_t *` — NULL on success with the result in a trailing out-parameter, non-NULL on failure, released exactly once with `aimux_error_free()`. The caller-allocated `AimuxError` struct, `aimux_error_clear`, `error_value` and the legacy `0 / -1` and `1 / 0` polarities are gone; there is one rule for every entry point. There is one code space, read with `aimux_error_code()`, segmented the way libsignal segments its FFI codes: 1–13 the AiMuxError variants (numbers unchanged), 100–105 the recorder's RecordingError, 200–206 failures the C boundary itself detects (NULL pointer, invalid UTF-8, malformed wire JSON, dead handle, re-entrant call, unserializable result, callback failure). A non-NULL error never carries code 0. `aimux_error_message()` answers for every code; the AiMuxError detail getters (`_retryable`, `_status`, `_retry_ms`, `_provider_code`, `_provider_message`, `_request_id`, `_response_body`, `_model_id`, `_model_type`, `_provider_id`) take the same pointer and answer NULL / -1 / 0 when the code does not own the field. There are no projections, no error family enum and no error registry: an error is a `Box::into_raw` handed to the caller, never a u64 handle. Kotlin, Java, Swift, Flutter and Go rebuild the separate types from the code range — the engine's error hierarchy, a distinct recording error, and the language's own invariant/plain error for 200–206 — so the C type system never has to express Rust's. Node and Python are untouched: they do not cross the C ABI. Go keeps master's (#157) lifecycle model; only the ABI call sites and the error decoder change. Router/MoA member lookup rejects any dead handle (subsuming #157's `get_models`). The C/C++ examples, docs/api/* and RFC-0030 follow. Header export count in `header_and_exports_agree` updated.
1 parent 8eb5309 commit d6ada6c

82 files changed

Lines changed: 11098 additions & 7587 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ difference: aimux is an access layer, those are orchestration layers.
4949
fallback (RFC-0021); `MoaModel` aggregates parallel reference models
5050
mixture-of-agents style (RFC-0022). Both are plain `LanguageModel`s.
5151
- **Config-driven provider registry** — `provider-registry.json` describes
52-
each of the 250 OpenAI-compatible providers (base URL, env var, profile
52+
each of the 251 OpenAI-compatible providers (base URL, env var, profile
5353
quirks: top_k, tools, response_format, streaming usage, max_tokens key);
5454
one unified `provider(name, ...)` entry in every binding.
5555
- **Fast and small** — Rust core, release profile tuned for binary size
@@ -88,25 +88,25 @@ middleware, and telemetry per request).
8888
2000 requests; Node grew only 2 MB.
8989
- **Stable tail latency** — no GC pauses means P99 stays flat even under CPU
9090
contention; the JS SDK's P99 spikes to 12.87 ms on a single core.
91-
- **FFI boundary is cheap** — serialization is ~50% of overhead only on large
91+
- **C ABI overhead is low** — serialization is ~50% of overhead only on large
9292
payloads; in real LLM requests (3–10 s) it is <0.1%.
9393

9494
## Architecture
9595

9696
```
9797
aimux/
9898
├── aimux-core # Core abstractions: LanguageModel / Provider / Message / StreamPart
99-
├── aimux-providers # 290+ provider implementations (250 registry-backed + native)
99+
├── aimux-providers # 329 provider implementations (251 registry-backed + native)
100100
├── aimux-stream # SSE / NDJSON stream parsing
101101
├── aimux-provider-utils # HTTP utilities: retry, backoff, error parsing, API-key loading
102-
├── aimux-ffi # C ABI (handles + JSON results + AimuxError out-param) for non-native bindings
102+
├── aimux-ffi # C ABI (opaque handles + JSON results + owned aimux_error_t *) for non-native bindings
103103
└── tools/ # aimux-cli (cache probe) · aimux-replay · aimux-web (console)
104104
```
105105

106106
```
107107
┌─ native path ──→ aimux-core + aimux-providers (direct Rust types + async)
108108
bindings ──┤
109-
└─ C ABI path ──→ aimux-ffi (handles + JSON results + AimuxError *err)
109+
└─ C ABI path ──→ aimux-ffi (handles + JSON results + owned errors)
110110
```
111111

112112
## Installation
@@ -248,7 +248,7 @@ retryable timeouts, in every binding ([RFC-0028](rfc/0028-transcription-streamin
248248
TypeScript:
249249

250250
```typescript
251-
import { openaiTranscription, startTranscriptionSession } from '@arcships/aimux'
251+
import { openaiTranscription, startTranscriptionSession } from '@arcships/aimux/raw'
252252

253253
const model = await openaiTranscription(process.env.OPENAI_API_KEY!, 'gpt-realtime-whisper')
254254
const session = await startTranscriptionSession(model, null)
@@ -367,8 +367,8 @@ Tests run on cassette playback — no network and no keys. See
367367
| [0018](rfc/0018-codex-subscription.md) | Codex subscription channel provider (evaluation) |
368368
| [0019](rfc/0019-session-affinity.md) | Session affinity lightweight support |
369369
| [0014](rfc/0014-logging.md) | Logging (`AIMUX_LOG` controls) |
370-
| [0015](rfc/0015-cache-trace-audit.md) | Cache-hit tracing & audit (TraceLayer / verdict engine) |
371-
| [0020](rfc/0020-pi-agent-integration.md) | Pi Agent integration — aimux as a Pi package (provider registry → Pi models) |
370+
| [0015](rfc/0015-cache-trace-audit.md) | Cache-hit tracing & audit (TraceLayer / verdict evaluator) |
371+
| [0020](rfc/0020-external-provider-config.md) | External OpenAI-compatible provider config (runtime registry overrides) |
372372
| [0021](rfc/0021-composite-model-routing.md) | RouterModel — composite model routing with fallback |
373373
| [0022](rfc/0022-moa-single-fanout.md) | MoaModel — single-fanout mixture-of-agents |
374374
| [0023](rfc/0023-runtime-request-recording.md) | Request recording & replay (JSONL, ring, redaction) |

‎aimux-ffi/Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ categories = ["api-bindings", "asynchronous", "development-tools::ffi"]
99
documentation = "https://docs.rs/aimux-ffi"
1010

1111
[lib]
12-
# Produce linkable artifacts for C ABI consumers (Swift / Kotlin / C).
12+
# Produce linkable artifacts for C ABI consumers
13+
# (C/C++, Go, Kotlin, Java, Swift, and Flutter).
1314
# `rlib` lets other Rust crates depend on this crate too.
1415
crate-type = ["cdylib", "staticlib", "rlib"]
1516

‎aimux-ffi/aimux-error.h‎

Lines changed: 84 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
/**
22
* aimux-error.h — C error model for aimux-ffi.
33
*
4-
* Transport: fallible calls use return-value sentinels (0 / NULL) for success
5-
* or failure. Optional details go through AimuxError *err (NULL = discard).
4+
* Every fallible function returns `aimux_error_t *`: NULL on success and an
5+
* owned error on failure. The normal result is written to the function's
6+
* trailing out-parameter, which remains at its documented sentinel on failure.
67
*
7-
* Modeling: flat error codes (13 AiMuxError variants + OK + UNKNOWN) and one
8-
* plain 40-byte struct. Check the function return value first; only then read
9-
* *err. On failure the callee overwrites every field and allocates `message`;
10-
* the caller owns it and must release it with aimux_free_string(). On success
11-
* *err is left untouched.
8+
* Every non-NULL error has one non-zero `aimux_error_code_t` and one message.
9+
* Codes 1..13 come from `AiMuxError`, 100..105 from `RecordingError`, and
10+
* 200..206 identify failures detected while crossing the C ABI. Higher-level
11+
* bindings reconstruct their native error types from that code; they map all
12+
* 200..206 codes to the language's existing argument/state/invariant error.
13+
*
14+
* Strings returned by getters are owned by the caller and must be released
15+
* with `aimux_free_string()`. Release the error itself exactly once with
16+
* `aimux_error_free()`; passing NULL to either release function is safe.
1217
*/
1318

1419
#ifndef AIMUX_ERROR_H
@@ -20,14 +25,22 @@
2025
extern "C" {
2126
#endif
2227

28+
/** An error returned by a failed Aimux C ABI invocation. Opaque and owned. */
29+
typedef struct aimux_error aimux_error_t;
30+
2331
/**
24-
* Machine-readable codes. Values 2..14 map to aimux-core's 13 current
25-
* AiMuxError variants, numbered consecutively; 1 (UNKNOWN) is the
26-
* FFI-only fallback for errors with no core variant.
32+
* Stable machine-readable code returned by `aimux_error_code()`.
33+
*
34+
* Existing values are never renumbered or reused. New values are appended
35+
* within a reserved range or added in a new range. A non-NULL error must never
36+
* report AIMUX_OK; an unknown non-zero value means the header and loaded
37+
* library are incompatible.
2738
*/
28-
typedef enum AimuxErrorCode {
39+
typedef enum aimux_error_code {
2940
AIMUX_OK = 0,
30-
AIMUX_E_UNKNOWN = 1,
41+
42+
/* AiMuxError: 1..13. */
43+
AIMUX_E_OTHER = 1,
3144
AIMUX_E_JSON_PARSE = 2,
3245
AIMUX_E_INVALID_RESPONSE_DATA = 3,
3346
AIMUX_E_TOOL = 4,
@@ -40,55 +53,70 @@ typedef enum AimuxErrorCode {
4053
AIMUX_E_API_CALL = 11,
4154
AIMUX_E_TIMEOUT = 12,
4255
AIMUX_E_ABORTED = 13,
43-
AIMUX_E_OTHER = 14
44-
} AimuxErrorCode;
4556

46-
/**
47-
* Error report filled by aimux-ffi on failure when the caller passed a
48-
* non-NULL AimuxError *.
49-
*
50-
* On failure: code != AIMUX_OK; message is a non-empty NUL-terminated UTF-8
51-
* string allocated by aimux — release it with aimux_free_string(). status is
52-
* the HTTP status or -1; retry_ms is the retry hint or -1 (0 = retry
53-
* now). error_value is the lossless machine-readable form of the source
54-
* error — the externally-tagged JSON of aimux-core's AiMuxError, e.g.
55-
* {"ApiCall":{"status_code":401,"message":"..."}} — or NULL when the
56-
* failure was synthesized at the FFI boundary (bad argument, invalid handle)
57-
* and has no core error value. Release it with aimux_free_string() too.
58-
*
59-
* Initialize with aimux_error_clear() (or `= {0}` and set status/retry_ms
60-
* yourself) before first use so the owned pointers are valid to free.
57+
/* RecordingError: 100..105. */
58+
AIMUX_E_RECORDING_INIT = 100,
59+
AIMUX_E_RECORDING_OPEN_FILE = 101,
60+
AIMUX_E_RECORDING_SPAWN = 102,
61+
AIMUX_E_RECORDING_WRITER_GONE = 103,
62+
AIMUX_E_RECORDING_FLUSH_TIMEOUT = 104,
63+
AIMUX_E_RECORDING_WRITE = 105,
64+
65+
/* Failures detected while crossing the C ABI: 200..206. */
66+
AIMUX_E_FFI_NULL_POINTER = 200,
67+
AIMUX_E_FFI_INVALID_UTF8 = 201,
68+
AIMUX_E_FFI_INVALID_WIRE_JSON = 202,
69+
AIMUX_E_FFI_INVALID_HANDLE = 203,
70+
AIMUX_E_FFI_REENTRANT_CALL = 204,
71+
AIMUX_E_FFI_RESULT_SERIALIZATION = 205,
72+
AIMUX_E_FFI_CALLBACK_FAILURE = 206
73+
} aimux_error_code_t;
74+
75+
/** Release an error. NULL-safe; call exactly once for a non-NULL error. */
76+
void aimux_error_free(aimux_error_t *error);
77+
78+
/** AIMUX_OK for NULL; otherwise the error's single non-zero code. */
79+
int32_t aimux_error_code(const aimux_error_t *error);
80+
81+
/** Human-readable description for every code; caller frees the result. */
82+
char *aimux_error_message(const aimux_error_t *error);
83+
84+
/*
85+
* AiMuxError facts. These getters answer only for the documented AiMuxError
86+
* code and return NULL / -1 / 0 for every RecordingError, C ABI failure,
87+
* unrelated AiMuxError code, or NULL.
6188
*/
62-
typedef struct AimuxError {
63-
AimuxErrorCode code;
64-
int status;
65-
int64_t retry_ms;
66-
char *message;
67-
char *error_value;
68-
/**
69-
* Reserved for future ABI extension. Must be zero; the callee zeroes it
70-
* on failure. The struct size is part of the caller-allocated ABI and
71-
* can never change — this slot is the only room left to grow.
72-
*/
73-
void *reserved[1];
74-
} AimuxError;
89+
90+
/** 1 when retrying may help; only AIMUX_E_API_CALL can answer 1. */
91+
int32_t aimux_error_retryable(const aimux_error_t *error);
7592

7693
/**
77-
* Reset to OK / no hint / no strings. Does not free previous message /
78-
* error_value — release those with aimux_free_string() first if they were
79-
* set by a failed call.
94+
* Observed HTTP status for AIMUX_E_API_CALL; 401 for
95+
* AIMUX_E_TOKEN_EXPIRED; -1 otherwise or when no response was observed.
8096
*/
81-
static inline void aimux_error_clear(AimuxError *e) {
82-
if (!e) {
83-
return;
84-
}
85-
e->code = AIMUX_OK;
86-
e->status = -1;
87-
e->retry_ms = -1;
88-
e->message = 0;
89-
e->error_value = 0;
90-
e->reserved[0] = 0;
91-
}
97+
int32_t aimux_error_status(const aimux_error_t *error);
98+
99+
/* AIMUX_E_API_CALL — returned strings are caller-owned. */
100+
101+
/** Retry hint in milliseconds (0 = retry now), or -1 when absent. */
102+
int64_t aimux_error_retry_ms(const aimux_error_t *error);
103+
/** Provider's own error code, e.g. "insufficient_quota". */
104+
char *aimux_error_provider_code(const aimux_error_t *error);
105+
/** Failure text without Aimux's composed prefix. */
106+
char *aimux_error_provider_message(const aimux_error_t *error);
107+
/** Provider request id. */
108+
char *aimux_error_request_id(const aimux_error_t *error);
109+
/** Raw provider response body. */
110+
char *aimux_error_response_body(const aimux_error_t *error);
111+
112+
/* AIMUX_E_NO_SUCH_MODEL — returned strings are caller-owned. */
113+
114+
char *aimux_error_model_id(const aimux_error_t *error);
115+
char *aimux_error_model_type(const aimux_error_t *error);
116+
117+
/* AIMUX_E_NO_SUCH_PROVIDER — returned string is caller-owned. */
118+
119+
char *aimux_error_provider_id(const aimux_error_t *error);
92120

93121
#ifdef __cplusplus
94122
}

0 commit comments

Comments
 (0)