Unified LLM service access layer — one API to access 325 AI providers
Flutter/Dart wraps the Rust core through the aimux-ffi C ABI (via dart:ffi).
On pub.dev (publisher: arcships.ai):
flutter pub add aimuxThe package is a Flutter plugin: the Rust core ships inside it —
libaimux_ffi.so per ABI (Android) and aimux_ffi.xcframework (iOS) are
embedded at publish time, so no extra download or build step is needed.
iOS integrates via CocoaPods (ios/aimux.podspec) — Flutter's SwiftPM
integration does not link plugin binary targets as of Flutter 3.44, so the
podspec is the integration path; the vendored aimux_ffi.xcframework slice is
force-loaded into the app binary and resolved with DynamicLibrary.process().
While developing against the repo, depend on the path:
dependencies:
aimux:
path: bindings/flutterDesktop (Linux/macOS/Windows) is supported for development and tests: the
library is resolved from the platform library path — build it once with
cargo build -p aimux-ffi --release and point LD_LIBRARY_PATH
(DYLD_LIBRARY_PATH on macOS) at target/release.
The binding loads the platform library at runtime
(libaimux_ffi.so / libaimux_ffi.dylib / aimux_ffi.dll) — ship it with
your app or place it where the loader can find it.
final model = Model.openai('sk-...', 'gpt-4o', baseUrl: 'http://localhost:3000');
final result = model.generateText('What is Rust?');
model.close();All 251 registry-backed OpenAI-compatible providers are reachable by name;
ProviderName holds the constants:
Scope:
provider(name)covers only the 251 registry OpenAI-compatible providers; Anthropic/Google/multimodal/local → typed factories (Model.anthropic(apiKey, modelId)); custom endpoints → base-URL variant. Full list: providers.md.
// 推荐:ProviderName.groq 常量(补全 + 防拼写错误)
final model = Model.provider(ProviderName.groq, 'llama-3.3-70b');
final result = model.generateText('Hello');
model.close();
// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}):
final model2 = Model.provider('groq', 'llama-3.3-70b', apiKey: 'sk-...');
model2.close();Unknown names throw NoSuchProviderError (payload: the provider id); valid
names come from the generated ProviderName constants.
Two independent aimux exception types, mirroring two Rust types — neither
shares a base with the other (both just implements Exception):
| Source | Rust | Dart | C code |
|---|---|---|---|
| AiMux | AiMuxError |
AimuxException hierarchy |
1..17 (4 retired) |
| recorder | RecordingError |
RecordingException |
100..105 |
Every fallible C call returns an opaque aimux_error_t * (NULL =
success, result in the trailing out-parameter). The binding has one decoder
(errors.dart): expectAimuxError(e, context) for model calls,
expectRecordingError(e, context) for initRecording / recordingTryFlush,
expectFfiError(e, context) for utilities that can only fail in the C ABI.
One unified code selects 1..17, 100..105, or 200..206; each decoder copies the
relevant fields, releases the error with aimux_error_free exactly once, and
throws the matching AimuxException subclass / RecordingException. Codes
200..206 throw the native
StateError('aimux ffi: …') — see C ABI failures below.
AiMuxError values throw an AimuxException subclass hierarchy
(idiomatic Dart — is / on type checks, not stringly code switches):
Exception (implements)
└── AimuxException
├── JSONParseError / InvalidResponseDataError
├── InvalidArgumentError / InvalidPromptError
├── TokenExpiredError // status 401
├── UnsupportedFunctionalityError
├── NoSuchModelError / NoSuchProviderError
├── APICallError // every HTTP-shaped failure; branch on status
├── RetryError // reason, errors, lastError
├── AimuxTimeoutError
├── RequestAbortedError
├── NoSuchToolError // code 15; tool not in the supplied tool set
├── InvalidToolInputError // code 16; tool arguments failed to parse/validate
├── ToolCallRepairError // code 17; a repairToolCall hook itself failed
└── OtherError
Every instance has message, code (AimuxErrorCode constants matching C
aimux_error_code_t), status (HTTP or -1), retryMs (hint or -1;
0 = retry now) and retryable. Per-code payload lives on the carrying
subclass only: APICallError.providerCode / .providerMessage / .responseBody
(String?), NoSuchModelError.modelId / .modelType, and
NoSuchProviderError.providerId. A code outside the enum is a header/library
mismatch and fails with StateError, not an error type.
subclass only: APICallError.providerCode / .providerMessage / .requestId / .responseBody
(String?), NoSuchModelError.modelId / .modelType,
NoSuchProviderError.providerId, NoSuchToolError.toolName /
.availableTools (List<String>?, null when no tool set was supplied),
InvalidToolInputError.toolName / .toolInput (the raw argument text), and
ToolCallRepairError.originalError (the repaired-over error decoded from its
wire JSON — the same shape as ToolCall.error). A code outside the enum is a
header/library mismatch and fails with StateError, not an error type.
APICallError additionally exposes the sanitized URL/request values,
response headers/body, parsed provider data/code, and retryability.
RetryError preserves every concrete attempt error in errors, with
lastError and reason.
import 'package:aimux/aimux.dart'; // exports errors.dart
try {
final result = model.generateText('hi');
} on APICallError catch (e) {
if (e.status == 429) {
// rate limited; e.retryMs is the hint
} else if (e.status == 401) {
// auth failure
}
} on AimuxException catch (e) {
// any AiMuxError failure
}C ABI failures — the call was wrong, not the model — are native Dart
errors, never an aimux type. Raw JSON string parameters (configJson,
optsJson, valuesJson, …) are validated with jsonDecode in Dart before
the C call. Empty/blank is rejected for required raw-JSON params (valuesJson,
the optsJson of speech/image/video/rerank/search, and the configJson of
registerProviders / initProxy) and treated as "defaults" for optional ones
(nullable in the signature — provider/router/moa configJson,
embed/upload/transcription optsJson), matching the C ABI; use-after-close is
guarded locally. A returned C code in 200..206 is a binding/library invariant:
| Failure | Dart |
|---|---|
| bad raw JSON string parameter | FormatException (source = parameter name, e.g. config_json) — before C |
| string not representable on the wire (below) | FormatException — before C |
| use-after-close | StateError('… is closed') — before C |
| C code 200..206 | StateError('aimux ffi: …') (invariant) |
A Dart String is UTF-16 and may hold an unpaired surrogate — s.substring(0, n) cutting an emoji in half is enough. jsonDecode and jsonEncode both accept
one (jsonEncode writes it back as a \uD800 escape), but the Rust side's
serde_json rejects that escape, and toNativeUtf8 would otherwise silently
substitute U+FFFD. An interior NUL is worse: it is not rejected anywhere, it
just ends the C string early. Both are rejected in Dart with a FormatException
naming the parameter, for raw JSON parameters and for the JSON the binding
builds itself (prompt, options, ProviderConfig):
final chunk = userText.substring(0, 100); // may split an emoji
try {
model.generateText(chunk);
} on FormatException catch (e) {
// 'prompt: unpaired surrogate U+D83D at index 99 is not representable in UTF-8'
}The same check rejects a number Dart parses to Infinity (1e999) and nesting
deeper than 128 levels — both of which serde_json also rejects. Without it
each of these arrived as an uncatchable-looking invariant StateError from the
C ABI.
Recording errors are a separate type, mirroring Rust's independent
recording::RecordingError: recordingTryFlush() throws
RecordingException (implements Exception, not an AimuxException) with
code (RecordingErrorCode.init / openFile / spawn / writerGone / flushTimeout / write, from C aimux_error_code_t; only the last
three are reachable from a flush) and message. It returns normally when
nothing is recording. initRecording(dir) throws the same
RecordingException with code init / openFile / spawn when the recorder
cannot be constructed (the previous recorder stays in place). The legacy
recordingFlush() stays and never reports.
Use-after-close on a Dart wrapper (Model, ProviderHandle, the multimodal
models, TranscriptionSession, Files) throws StateError.
Stream terminal failures surface via Stream.addError with whatever the
decoder produced (AimuxException, or the native StateError for a C ABI
failure) and the stream is then closed — there is no on_error callback;
provider mid-stream StreamPart::Error is data on on_part. A
TranscriptionSession.nextPart timeout is a poll state, not an error:
AimuxTranscriptionTimeoutException (session still live); a normal end is
AimuxTranscriptionEndedException.
final model = Model.openai('sk-...', 'gpt-4o');
final result = model.generateText('What is Rust?');
model.close();Parameters, return value, and the
raw.contentvariants are documented in the API overview.
// streaming
final model = Model.openai('sk-...', 'gpt-4o');
final stream = model.streamText('Write a haiku');
await for (final part in stream) {
if (part.containsKey('TextDelta')) print(part['TextDelta']['delta']);
}
model.close();Stream part variants are documented in the API overview.
The raw Model speaks JSON maps. TypedModel wraps it with typed objects:
final model = TypedModel(Model.openai('sk-...', 'gpt-4o'));
final result = model.generateText('What is Rust?');
print(result.text); // typed GenerateTextResult
final stream = model.streamText('Write a haiku');
await for (final part in stream) {
if (part is StreamPartTextDelta) print(part.delta); // typed StreamPart variants
}
model.close();| API | Signature | Description |
|---|---|---|
TypedModel |
TypedModel(Model raw) — wrap a raw Model |
|
generateText |
GenerateTextResult generateText(String prompt, [GenerateTextOptions? options]) |
String prompt |
generateTextMessages |
GenerateTextResult generateTextMessages(List<ModelMessage> messages, [GenerateTextOptions? options]) |
Multi-turn typed messages |
streamText |
Stream<StreamPart> streamText(Object prompt, [GenerateTextOptions? options]) |
Yields typed StreamParts |
close |
void close() |
Release the native handle |
A tool call the model got wrong never fails generation: it comes back with
invalid: true and an error. Set GenerateTextOptions.repairToolCall to fix
it host-side (RFC-0035, mirroring the AI SDK's repairToolCall). Every invalid
call in the result is offered to the hook once; return a RawToolCall to
replace it, null to leave it alone. Both toolCalls and the matching
responseMessages part are rewritten, so the next turn replays the repaired
arguments.
final options = GenerateTextOptions(
tools: [Tool.function(weather)],
repairToolCall: (context) {
// context: the raw argument text, the error, the tool's input schema,
// the full tool set, the messages, the instructions.
if (context.toolCall.toolName != 'weather') return null;
final wrong = jsonDecode(context.toolCall.input) as Map<String, dynamic>;
return context.toolCall.copyWith(input: jsonEncode({'city': wrong['town']}));
},
);
final result = TypedModel(model).generateText('weather in Singapore?', options);The hook runs on the calling isolate after the native call has returned, so it
may itself call back into aimux (asking a model to rewrite the arguments, for
instance). Throwing means the repair failed: the call stays invalid and carries
a ToolCallRepairError whose cause is the thrown object's toString(). A
replacement that still fails schema validation does the same.
streamText repairs the ToolCall part in flight — tool-input deltas are
forwarded verbatim and in order — and is the only entry point that awaits an
async hook; the synchronous ones reject a Future with a StateError.
generateTextAsOpenAI repairs too: ChatCompletion carries no invalid
marker, so it repairs the native result and converts it
(Model.generateTextResultAsOpenAI). streamTextAsOpenAI does not reflect
repair — the chunk stream forwards the provider's argument deltas verbatim.
RawToolCall carries every provider field (providerExecuted,
thoughtSignature, providerMetadata), and the core adopts a replacement as
returned — build it with context.toolCall.copyWith(input: …) so those
fields survive the repair.
bindings/flutter/lib/types.dart declares the typed model surface (with
toJson / fromJson on each): Role, FinishReasonUnified,
ReasoningEffort, TokenUsage, Usage, FinishReason, ToolCall,
FunctionTool, Tool, ToolChoice, ResponseMetadata, GenerateContent
(sealed), GenerateResult, GenerateTextResult, GenerateTextOptions,
ModelMessage, StreamPart (sealed), FileBytes, FileData, ContentPart,
RawToolCall / ToolCallRepairContext / RepairToolCall (tool-call repair).
ToolCall carries providerMetadata plus invalid (set by Core when tool
lookup, input parse, or schema validation fails, even after repair) and error
(the serialized AiMuxError for that failure).
Text generation and streaming are supported. Multimodal features (embedding, TTS, STT, image, video, rerank, search, files) are reachable only through the raw C ABI until the wrappers are extended — see the coverage matrix.
The cache-probing / trace API (RFC-0015 — aimux_trace_new and the
aimux_trace_* queries) is not exposed by this binding: there is no
trace() wrapper and no trace query method, so the trace store cannot be
reached from Dart. Use the raw C ABI if you need it.