Skip to content

Latest commit

 

History

History
263 lines (214 loc) · 11.7 KB

File metadata and controls

263 lines (214 loc) · 11.7 KB

aimux · Kotlin API

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..17 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
      ├── InvalidArgumentError / InvalidPromptError
      ├── TokenExpiredError          // 401, refresh and retry
      ├── UnsupportedFunctionalityError
      ├── NoSuchModelError / NoSuchProviderError   // modelId + modelType / providerId
      ├── APICallError               // every HTTP-shaped failure; classify on status
      │                              // + providerCode, providerMessage, responseBody, url, requestBodyValues, responseHeaders, data (null when absent)
      ├── RetryError                 // the retry loop gave up; reason, errors (oldest first), lastError
      ├── TimeoutError / RequestAbortedError
      ├── NoSuchToolError            // toolName + availableTools (null = no tool set supplied)
      ├── InvalidToolInputError      // toolName + toolInput (the raw argument text)
      ├── ToolCallRepairError        // originalError (wire JSON, same encoding as ToolCall.error)
      └── 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
}
Field Meaning
code AIMUX_E_* matching C aimux_error_code_t (1..17; 1 is the catch-all Other; 4 is retired — the legacy Tool variant; 14 = Retry)
status HTTP status when known; otherwise -1
retryMs Rate-limit hint in ms; -1 if none; 0 = retry immediately

RetryError preserves the per-attempt history: reason (RetryErrorReason.MAX_RETRIES_EXCEEDED — every permitted attempt failed with a retryable error — or ERROR_NOT_RETRYABLE — a later attempt failed non-retryably), errors (oldest first, each itself an AimuxException — typically APICallError with its full detail), and lastError.

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.

Failure Kotlin / Java
bad raw JSON argument IllegalArgumentException("promptJson: …") (before C)
use-after-close IllegalStateException("Model is closed")
C code 200..206 IllegalStateException("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)
API Signature
TypedModel.openai / TypedModel.anthropic fun openai(apiKey: String, modelId: String): TypedModel (+ baseUrl overload)
TypedModel.of fun of(model: Model): TypedModel — wrap an existing raw Model
generateText fun generateText(prompt: String, options: GenerateTextOptions? = null): GenerateTextResult
generateText fun generateText(messages: List<ModelMessage>, options: GenerateTextOptions? = null): GenerateTextResult
streamText callback-based streaming (onPart: (StreamPart) -> Unit, onDone, onError)
streamTextSequence fun 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:

Exception Meaning
returns a String the next part (JSON TranscriptionStreamPart)
AimuxTranscriptionEndedException the stream finished normally
AimuxTranscriptionTimeoutException no part in time — retryable, the session stays live
AimuxException subclasses the 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).

MultimodalTypes.kt includes VideoCallOptions.poll: VideoPollOptions?; intervalMs and timeoutMs serialize as interval_ms / timeout_ms for the Core-owned video status loop. ToolCall (top-level and StreamPart.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).

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.