aimux

August 22, 2026 · View on GitHub

Unified LLM service access layer — one API to access 325 AI providers

Swift wraps the Rust core through the aimux-ffi C ABI (module CAimuxFFI), with ARC-managed model handles.

Install

Add the package via SPM (the git tag is the version):

.package(url: "https://github.com/arcships/aimux", from: "0.3.0"),
.target(name: "YourApp", dependencies: [.product(name: "Aimux", package: "aimux")])

The binding loads libaimux_ffi.dylib at runtime — make it available via pkg-config (aimux-ffi.pc) or put the prebuilt library from GitHub Releases on the loader path (DYLD_LIBRARY_PATH / LD_LIBRARY_PATH).

Quick Start

import Aimux

let model = try Model.openai(apiKey: "sk-...", modelId: "gpt-4o", baseUrl: "http://localhost:3000")
let result = try model.generateText(prompt: "\"What is Rust?\"")
print(result)

Providers

All 251 registry-backed OpenAI-compatible providers are reachable by name; ProviderName is an enum with one case per provider:

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 enum case(类型检查 + 补全)
let model = try Model.provider(name: ProviderName.Groq.rawValue, modelId: "llama-3.3-70b")
let result = try model.generateText(prompt: "\"Hello\"")

// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}):
let model2 = try Model.provider(name: "groq", apiKey: "sk-...", modelId: "llama-3.3-70b")

Unknown names throw .noSuchProvider (payload: the provider id); valid names come from the generated ProviderName enum.

Text Generation

generateText(prompt:options:) returns the raw GenerateResult JSON string.

import Aimux

let model = try Model.openai(apiKey: "sk-...", modelId: "gpt-4o")
// prompt is a JSON string; JSON-quote plain text prompts
let result = try model.generateText(prompt: "\"What is Rust?\"")
// or pass multi-role messages
let result2 = try model.generateText(prompt: #"[{"role":"user","content":"Hello"}]"#)
print(result2)

Parameters, return value, and the raw.content variants are documented in the API overview.

Streaming Generation

streamText(prompt:options:onPart:onDone:onError:) delivers each part as a StreamPart JSON string.

model.streamText(prompt: "\"Write a haiku\"") { part in
    print(part) // StreamPart JSON string
} onDone: {
    print("[done]")
} onError: { error in
    print("error: \(error)")
}

API Surface

APISignatureDescription
Model.openai / Model.anthropicstatic func openai(apiKey: String, modelId: String) throws -> ModelCreate a model (official base URL)
Model.openai / Model.anthropicstatic func openai(apiKey: String, modelId: String, baseUrl: String) throws -> ModelCreate a model (custom base URL)
generateTextfunc generateText(prompt: String, options: String? = nil) throws -> StringNon-streaming; returns GenerateResult JSON
streamTextfunc streamText(prompt: String, options: String? = nil, onPart: @escaping (String) -> Void, onDone: @escaping () -> Void, onError: @escaping (any Error) -> Void)Streaming via push callbacks
streamTextAsyncfunc streamTextAsync(prompt: String, options: String? = nil) -> AsyncThrowingStream<String, Error>Streaming as an AsyncSequence
generatefunc generate(prompt: String, options: [String: Any]? = nil) throws -> [String: Any]Convenience: parses generateText into a dictionary
Model.initRecordingstatic func initRecording(dir: String) throwsStart JSONL recording; throws RecordingError (.initFailed / .openFile / .spawn) when the recorder cannot be constructed; the previous recorder stays in place
Model.recordingTryFlushstatic func recordingTryFlush() throwsChecked recorder flush; throws RecordingError (own type, see below). Legacy recordingFlush() stays and never reports

Errors

Two independent aimux error types, mirroring two Rust types; neither inherits from the other, both share only Error: AimuxError (core AiMuxError) and RecordingError (recorder). C ABI codes 200...206 have no Swift type — see "C ABI failures" below.

Every fallible C function returns an opaque aimux_error_t * (OpaquePointer?): NULL = success (the result is in the trailing out-parameter), non-NULL = failure. One unified code selects AimuxError (1...13), RecordingError (100...105), or a C ABI failure (200...206). The three decoders enforce the range expected by each call and restore the Swift error type; 200...206 collapses to DecodingError.dataCorrupted. Every path copies its strings (freed with aimux_free_string) and then releases the returned error with aimux_error_free exactly once. A code outside the C enum is a header/library mismatch and yields the invariant DecodingError.dataCorrupted("aimux ffi: <context>: <message>"). Errors are not handles: aimux_drop_handle never sees one.

AimuxError is a structured Swift Error enum mapped from aimux_error_code_t (see aimux-error.h).

CaseC codeNotes
.jsonParseAIMUX_E_JSON_PARSE (2)JSON parse/serialize
.invalidResponseDataAIMUX_E_INVALID_RESPONSE_DATA (3)Malformed response / stream data
.toolAIMUX_E_TOOL (4)Tool-related failure
.invalidArgumentAIMUX_E_INVALID_ARGUMENT (5)Bad argument
.invalidPromptAIMUX_E_INVALID_PROMPT (6)Bad prompt JSON
.tokenExpiredAIMUX_E_TOKEN_EXPIRED (7)Expired token; status 401
.unsupportedFunctionalityAIMUX_E_UNSUPPORTED_FUNCTIONALITY (8)Unsupported feature
.noSuchModelAIMUX_E_NO_SUCH_MODEL (9)Registry miss
.noSuchProviderAIMUX_E_NO_SUCH_PROVIDER (10)Unknown provider id
.apiCallAIMUX_E_API_CALL (11)Every HTTP-shaped failure; branch on status (401 auth, 404 model, 429 rate limit)
.timeoutAIMUX_E_TIMEOUT (12)Request timed out
.abortedAIMUX_E_ABORTED (13)Request aborted
.otherAIMUX_E_OTHER (1)Unclassified core error

There are no binding-local cases: only aimux-core produces an AimuxError. Un-encodable typed input (typed streamText prompt/options) surfaces as the EncodingError JSONEncoder threw, undecodable library output as the DecodingError JSONDecoder threw. A code outside the expected range is a header/library mismatch and fails with DecodingError.dataCorrupted from the decoder, not an error type.

Every case carries message, status (Int?nil when C reports no status), retryMs (Int64?nil if none; 0 = retry now) and retryable. Three cases carry a typed payload as extra associated values: .apiCall(providerCode:providerMessage:requestId:responseBody:) (all optional), .noSuchModel(modelId:modelType:) and .noSuchProvider(providerId:); the same-named computed properties return nil on every other case. e.code returns the mapped aimux_error_code_t constant as Int32.

Recording errors are a separate type. Model.initRecording(dir:) and Model.recordingTryFlush() throw RecordingErrorstruct RecordingError: Error, Equatable { code: Code; message: String } with Code = .initFailed, .openFile, .spawn, .writerGone, .flushTimeout, .write (mirrors aimux_error_code_t in aimux-error.h; the first three come from initRecording, the last three from a flush). It is not a case of AimuxError and never appears in the code table above; catch it with catch let e as RecordingError.

C ABI failures are native errors, not an aimux type. The binding is a consumer of the C ABI: it catches misuse before the C call and maps codes 200...206 onto Swift's own errors.

FailureSwift
Bad raw JSON (prompt / options / configJson / values / recordingsJsonl strings, on every raw-JSON entry)DecodingError.dataCorrupted naming the parameter, thrown by the binding (JSONSerialization pre-check) before the C call. Optional parameters follow the FFI's "blank means default" rule; required ones reject an empty string
Use-after-close (TranscriptionSession, the only closeable handle)DecodingError.dataCorrupted "aimux: transcription session is closed" from pushAudio / inputDone / nextPart. Catchable, like Go's ErrClosed and Dart's StateError; defer { session.close() } can never crash the host app. Deliberately not AimuxTranscriptionEndedError — a pump loop that breaks on "ended" must not read a transcript its own close() truncated as complete
initRecordingRing(cap: 0)AimuxError.invalidArgument — the value goes to C and aimux-core classifies it (AIMUX_E_INVALID_ARGUMENT, "cap: must be > 0"), so a Swift caller sees exactly what a C caller sees. The function is throws
router([])DecodingError.dataCorrupted "aimux ffi: router: …" — C's zero-children failure, surfaced unchanged. Not a trap: the array is as likely to come from a .filter as from a literal, and router already throws
C code 200...206 (re-entrant call, NULL arg, invalid UTF-8, marshalling / callback / internal)DecodingError.dataCorrupted "aimux ffi: <context>: <message>" — a binding/library invariant, a correct binding never triggers it

No caller-supplied value traps the process. The binding contains exactly one preconditionFailure, in initLogging(level:), and it is unreachable: the only failure aimux_init_logging reports is a non-UTF-8 level, which a Swift String cannot produce (NULL and an unrecognized level string are both accepted — they fall back to the default).

Accessors:

do {
    _ = try model.generateText(prompt: "\"hi\"")
} catch let e as AimuxError {
    print(e.message, e.status, e.retryMs)
    if case .apiCall = e, e.status == 429, let ms = e.retryMs {
        // rate limited — back off `ms`
    }
}

Streaming: aimux_stream_text returns NULL after on_done, or an error object on failure (no on_done, no C onError callback). The Swift push API's onError is (any Error) -> Void and receives the decoded error unchanged (AimuxError, or DecodingError for bad raw JSON / a C ABI invariant); streamTextAsync throws the same.

Types

bindings/swift/Sources/Aimux/Types.swift declares lightweight Codable types mirroring the shared JSON shape — usable with the JSON-string APIs:

JSONValue (recursive JSON enum with stringValue / boolValue / doubleValue / intValue / arrayValue / objectValue accessors), Role, FinishReasonUnified, ReasoningEffort, FinishReason, TokenUsage, Usage, ResponseMetadata, Warning, FunctionTool, ProviderTool, Tool, ToolChoice, ResponseFormat, ContentPart, MessageContent, ModelMessage, ModelPrompt, ToolCall, FileBytes, FileData, GenerateContent, GenerateResult, GenerateTextResult, GenerateTextOptions, StreamPart (all Codable, Equatable).

Example:

let data = result.data(using: .utf8)!
let decoded = try JSONDecoder().decode(Usage.self, from: data)
print(decoded.inputTokens.total ?? 0)

Coverage

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.