aimux

August 22, 2026 · View on GitHub

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

Kotlin wraps the Rust core through the aimux-ffi C ABI (via JNA).

Install

Maven Central (publishing):

implementation("ai.arcships:aimux-kotlin:0.3.0")

JNA loads aimux_ffi by name — provide the native library (libaimux_ffi.so on Linux, libaimux_ffi.dylib on macOS, aimux_ffi.dll on Windows) from GitHub Releases on the JNA search path: java.library.path, LD_LIBRARY_PATH, or next to the JAR.

Quick Start

Model.openai("sk-...", "gpt-4o", "http://localhost:3000").use { model ->
    val result = model.generateText("\"What is Rust?\"")
}

Providers

All 251 registry-backed OpenAI-compatible providers are reachable by name; ai.arcships.aimux.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 常量(类型检查 + 补全)
Model.provider(name = ProviderName.GROQ, modelId = "llama-3.3-70b").use { model ->
    val result = model.generateText("\"Hello\"")
}

// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}):
Model.provider(name = "groq", apiKey = "sk-...", modelId = "llama-3.3-70b").use { model ->
    val result = model.generateText("\"Hello\"")
}

Unknown names throw NoSuchProviderError naming the requested provider (valid names come from the generated ProviderName constants).

Errors

Two aimux exception types, each mirroring its own Rust type — AiMux (AimuxException) and recorder (RecordingException). They share no base beyond RuntimeException; catch each on its own. Every fallible C call returns an aimux_error_t * (null = success, result in the out-parameter). The binding reads one unified code: 1..13 restores an AimuxException subclass, 100..105 restores RecordingException, and 200..206 becomes IllegalStateException("aimux ffi: …"). Payload getters are read only under their owning AiMuxError code. Each helper frees every string and frees the returned error once (aimux_error_free) — not a JSON error envelope on the primary path, and never a handle. Kotlin does not add a third aimux error type for C ABI failures.

AiMuxError values throw the AimuxException sealed hierarchy (exhaustive when in Kotlin; Java callers can still catch (AimuxException e)). It reads code, message, retryable for every code and the payload getters only under their owning code.

RuntimeException
 └── AimuxException          // code, status, retryMs, retryable
      ├── JSONParseError / InvalidResponseDataError
      ├── ToolError
      ├── InvalidArgumentError / InvalidPromptError
      ├── TokenExpiredError          // 401, refresh and retry
      ├── UnsupportedFunctionalityError
      ├── NoSuchModelError / NoSuchProviderError   // modelId + modelType / providerId
      ├── APICallError               // every HTTP-shaped failure; classify on status
      │                              // + providerCode, providerMessage, requestId, responseBody (null when absent)
      ├── TimeoutError / RequestAbortedError
      └── OtherError

A code outside the enum is a header/library mismatch and fails with IllegalStateException, not an error type.

import ai.arcships.aimux.*

try {
    model.generateText("\"hi\"")
} catch (e: TokenExpiredError) {
    // 401 — refresh the token and retry
} catch (e: APICallError) {
    // Classify on status: 429 → rate limited (e.retryMs),
    // 401 → auth, 404 → model not found, -1 → no HTTP response observed
} catch (e: AimuxException) {
    // e.code (AIMUX_E_*), e.status, e.retryMs
}
FieldMeaning
codeAIMUX_E_* matching C aimux_error_code_t (1..13; 1 is the catch-all Other)
statusHTTP status when known; otherwise -1
retryMsRate-limit hint in ms; -1 if none; 0 = retry immediately

Recording errors are a separate type, mirroring Rust's recording::RecordingError (C codes 100..105): initRecording() and recordingTryFlush() throw RecordingException(code: RecordingErrorCode, message) — a plain RuntimeException, not an AimuxException — with code one of INIT, OPEN_FILE, SPAWN, WRITER_GONE, FLUSH_TIMEOUT, WRITE. initRecording() reports INIT (dir could not be created), OPEN_FILE, SPAWN and leaves any previous recorder in place; a flush reports the last three. The legacy recordingFlush() stays and never reports.

C ABI failures. The binding validates what only the caller can get wrong before the C call: malformed raw JSON text (promptJson, optsJson, configJson, valuesJson; required arguments reject empty, optional empty = default, JSONL by line) throws IllegalArgumentException naming the Kotlin parameter; any method on a closed Model / ProviderHandle / multimodal model / TranscriptionSession throws IllegalStateException("X is closed"). Anything the C layer itself reports as 200..206 (dead or type-mismatched handle, re-entrant call, NULL / non-UTF-8 string, unserializable result, panicking callback, internal) is a binding or library invariant and surfaces as IllegalStateException("aimux ffi: …"). None of these are AimuxException.

FailureKotlin / Java
bad raw JSON argumentIllegalArgumentException("promptJson: …") (before C)
use-after-closeIllegalStateException("Model is closed")
C code 200..206IllegalStateException("aimux ffi: …") (binding/library invariant)

Local decode failures in TypedModel throw InvalidArgumentError. Stream setup and terminal failures throw the typed hierarchy; the raw Model.streamText has no onError parameter (the C ABI has no on_error callback), and TypedModel.streamText's onError reports local decode failures only.

Text Generation

Model.openai("sk-...", "gpt-4o").use { model ->
    val result = model.generateText("\"What is Rust?\"")
}

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

Streaming Generation

// streaming
Model.openai("sk-...", "gpt-4o").use { model ->
    // Raw Model has no onError: stream failures throw AimuxException from streamText.
    model.streamText("\"Write a haiku\"", onPart = { println(it) }, onDone = {})
}

Stream part variants are documented in the API overview.

TypedModel

The raw Model speaks JSON strings. TypedModel wraps it with typed objects:

val model = TypedModel.openai("sk-...", "gpt-4o")
val result = model.generateText("What is Rust?")
println(result.text)          // typed GenerateTextResult
println(result.usage?.inputTokens?.total)
APISignature
TypedModel.openai / TypedModel.anthropicfun openai(apiKey: String, modelId: String): TypedModel (+ baseUrl overload)
TypedModel.offun of(model: Model): TypedModel — wrap an existing raw Model
generateTextfun generateText(prompt: String, options: GenerateTextOptions? = null): GenerateTextResult
generateTextfun generateText(messages: List<ModelMessage>, options: GenerateTextOptions? = null): GenerateTextResult
streamTextcallback-based streaming (onPart: (StreamPart) -> Unit, onDone, onError)
streamTextSequencefun streamTextSequence(...): Sequence<StreamPart> — pull-based streaming

TypedModel is Closeable (use use { }); AiMuxError values surface as typed AimuxException subclasses (see Errors).

Streaming Transcription (STT)

Realtime transcription models (e.g. OpenAI gpt-realtime-whisper) support streaming sessions (RFC-0028): push audio chunks, then pull transcription parts. TranscriptionModel.startStream returns a TranscriptionSession (Closeable):

TranscriptionModel.openai("sk-...", "gpt-realtime-whisper").use { model ->
    model.startStream().use { session ->
        session.pushAudio(chunk)          // blocking (backpressure)
        session.inputDone()               // end-of-audio (idempotent)
        while (true) {
            try {
                val part = session.nextPart(timeoutMs = 500)
                println(part)             // JSON TranscriptionStreamPart
            } catch (e: TranscriptionSession.AimuxTranscriptionEndedException) {
                break                     // stream finished normally
            } catch (e: TranscriptionSession.AimuxTranscriptionTimeoutException) {
                // No part within timeoutMs — retryable: the session stays
                // live, just call nextPart again.
            }
        }
    }
}

nextPart(timeoutMs): timeoutMs > 0 waits at most that long; 0 polls immediately; < 0 waits indefinitely. Outcomes:

ExceptionMeaning
returns a Stringthe next part (JSON TranscriptionStreamPart)
AimuxTranscriptionEndedExceptionthe stream finished normally
AimuxTranscriptionTimeoutExceptionno part in time — retryable, the session stays live
AimuxException subclassesthe stream failed (typed hierarchy)

The timeout sentinel is deliberately not an AimuxException / TimeoutError — a timeout is not a stream failure, so catch it explicitly (same shape as the Go / Java / Swift / Flutter bindings). close() aborts and releases the session (idempotent).

Types

bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt declares the typed model surface: Role, FinishReasonUnified, ReasoningEffort, TokenUsage, Usage, FinishReason, ResponseMetadata, ToolCall, FunctionTool, ProviderTool, Tool (sealed), ToolChoice (sealed), ContentPart (sealed), MessageContent (sealed), ModelMessage, GenerateTextOptions, FileBytes / FileData (sealed), GenerateContent (sealed), GenerateResult, GenerateTextResult, StreamPart (sealed).

Coverage

Full multimodal surface — text generation, streaming, embedding, TTS, STT (incl. streaming TranscriptionSession), image, video, rerank, search, and file upload (Multimodal.kt + MultimodalTypes.kt), verified by mock-server end-to-end tests (no real network). See the coverage matrix.