Skip to content

Latest commit

 

History

History
349 lines (290 loc) · 19 KB

swift.md

File metadata and controls

349 lines (290 loc) · 19 KB

aimux · Swift API

Legacy (0.5 API). This guide still describes the 0.5 binding API, which V4 removed: errors are now the shared ErrorEnvelope {kind, code, message, error} and carry no retry hint (retryMs / retry_ms / aimux_error_retry_ms); read a retry delay from error.responseHeaders (retry-after-ms, then retry-after), as with the AI SDK. The current API is in bindings/swift/README.md. The rewrite is tracked in #277.

Unified LLM service access layer — one API to access 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

The vendor packages and 282 registry-backed OpenAI-compatible providers are reachable by string name:

Scope: provider(name) reaches vendor packages and the 282 preset rows; typed factories remain available. Custom endpoints use the base-URL variant. Full list: providers.md.

let model = try Model.provider(name: "groq", modelId: "llama-3.3-70b")
let result = try model.generateText(prompt: "\"Hello\"")

// Optional config JSON ({"baseUrl": "..."}; unknown keys are rejected):
let model2 = try Model.provider(name: "groq", apiKey: "sk-...", modelId: "llama-3.3-70b",
                                configJson: #"{"baseUrl":"https://proxy.example/v1"}"#)

Unknown names throw .noSuchProvider (payload: the provider id).

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 content variants are documented in the API overview.

The JSON is the AI SDK JSON: camelCase fields, optional fields absent (never null; an unknown value such as body keeps a JSON null as JSONValue.null; a response timestamp is a toISOString() string such as "2025-03-06T13:50:19.000Z", which ISO8601DateFormatter with .withFractionalSeconds reads), unions tagged by type ({"type":"tool-call","toolCallId":…}). The typed overloads take and return Codable values with exactly that shape:

let options = GenerateTextOptions(
    tools: [.function(FunctionTool(name: "get_weather", inputSchema: schema))],
    toolChoice: .required
)
let result = try model.generateText(prompt: .text("Weather in Tokyo?"), options: options)
print(result.text, result.toolCalls.first?.toolName ?? "-", result.usage.inputTokens.total ?? 0)

// Send a tool result back: the part carries `toolName` and a typed `output`.
let followUp: [ModelMessage] = [
    .user("Weather in Tokyo?"),
    ModelMessage(role: .assistant, content: .parts([
        .toolCall(toolCallId: "call_1", toolName: "get_weather", input: .object(["location": .string("Tokyo")]))
    ])),
    ModelMessage(role: .tool, content: .parts([
        .toolResult(toolCallId: "call_1", toolName: "get_weather",
                    output: .json(.object(["temperature": .number(22)])))
    ])),
]

ToolResultOutput is .text, .json, .executionDenied(reason:), .errorText, .errorJson or .content([ToolResultContent]) ({"type":"text"|"json"|"execution-denied"|"error-text"|"error-json"|"content"}).

Streaming Generation

streamText(prompt:options:onPart:onDone:onError:) delivers each part as a TextStreamPart JSON string ({"type":"text-delta","id":…,"text":…}); the typed overload decodes it into TextStreamPart.

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

Tool-Call Repair

When the model gets a tool call's arguments wrong, aimux does not fail the generation: the call comes back with invalid == true and an error. Set GenerateTextOptions.repairToolCall to get one shot at fixing it, mirroring the AI SDK's repairToolCall. It may issue a second aimux request of its own — on the streaming path the binding runs it off the native callback thread for exactly that reason, since the FFI re-entrancy guard is thread-local.

var options = GenerateTextOptions(tools: [weatherTool])
options.repairToolCall = { context in
    // context.toolCall.input is the model's raw argument TEXT,
    // context.error the failure, context.inputSchema the tool's schema.
    let fixed = try model.generateText(
        prompt: .text("Fix these arguments: \(context.toolCall.input)")
    )
    return RawToolCall(toolCallId: context.toolCall.toolCallId,
                       toolName: context.toolCall.toolName,
                       input: fixed.text) // nil instead: leave the call as it is
}
let result = try model.generateText(prompt: .text("weather in Singapore?"), options: options)

Return a RawToolCall to replace the call (it is re-validated — a replacement that still fails validation stays invalid, now carrying a ToolCallRepair error), nil to keep the call and its original error, or throw to fail the repair with the thrown error's message as cause. A call generated without a tool set is never repaired and the function is not invoked for it.

Applies to generateText, generateObject, consumeStreamText (patching toolCalls and the replayed responseMessages) and to streamText, whose .toolCall part arrives repaired while the .toolInputDelta parts before it are the provider's own, forwarded verbatim. It does not apply to the OpenAI-format outputs (generateTextAsOpenAI / streamTextAsOpenAI): ChatCompletion carries no invalid marker, so there is nothing to drive a repair from. The function is host-side only — it never reaches the library as part of the options JSON.

The three pure functions behind it are public as well, for hosts driving the loop themselves: toolCallRepairContext(toolCall:prompt:options:) (returns the JSON literal "null" for a call made without tools), applyToolCallRepair(toolCall:options:reply:) and applyToolCallRepairToResult(result:options:toolCallId:reply:).

API Surface

API Signature Description
Model.openai / Model.anthropic static func openai(apiKey: String, modelId: String) throws -> Model Create a model (official base URL)
Model.openai / Model.anthropic static func openai(apiKey: String, modelId: String, baseUrl: String) throws -> Model Create a model (custom base URL)
generateText func generateText(prompt: String, options: String? = nil) throws -> String Non-streaming; returns GenerateResult JSON
streamText func streamText(prompt: String, options: String? = nil, onPart: @escaping (String) -> Void, onDone: @escaping () -> Void, onError: @escaping (any Error) -> Void) Streaming via push callbacks
streamTextAsync func streamTextAsync(prompt: String, options: String? = nil) -> AsyncThrowingStream<String, Error> Streaming as an AsyncSequence
repairToolCall var repairToolCall: RepairToolCall? on GenerateTextOptions, i.e. (ToolCallRepairContext) throws -> RawToolCall? One-shot host-side repair of invalid tool calls (see above); generateTextAsOpenAI repairs the native result then converts it, streamTextAsOpenAI does not reflect repair
generate func generate(prompt: String, options: [String: Any]? = nil) throws -> [String: Any] Convenience: parses generateText into a dictionary
Model.initRecording static func initRecording(dir: String) throws Start JSONL recording; throws RecordingError (.initFailed / .openFile / .spawn) when the recorder cannot be constructed; the previous recorder stays in place
Model.recordingTryFlush static func recordingTryFlush() throws Checked 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...20; 4 retired), 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).

Case C code Notes
.jsonParse AIMUX_E_JSON_PARSE (2) JSON parse/serialize
.invalidResponseData AIMUX_E_INVALID_RESPONSE_DATA (3) Malformed response / stream data
.invalidArgument AIMUX_E_INVALID_ARGUMENT (5) Bad argument
.invalidPrompt AIMUX_E_INVALID_PROMPT (6) Bad prompt JSON
.tokenExpired AIMUX_E_TOKEN_EXPIRED (7) Expired token; status 401
.unsupportedFunctionality AIMUX_E_UNSUPPORTED_FUNCTIONALITY (8) Unsupported feature
.noSuchModel AIMUX_E_NO_SUCH_MODEL (9) Registry miss
.noSuchProvider AIMUX_E_NO_SUCH_PROVIDER (10) Unknown provider id
.apiCall AIMUX_E_API_CALL (11) Every HTTP-shaped failure; branch on status (401 auth, 404 model, 429 rate limit)
.retry AIMUX_E_RETRY (14) Complete attempt history and stop reason
.timeout AIMUX_E_TIMEOUT (12) Request timed out
.aborted AIMUX_E_ABORTED (13) Request aborted
.noSuchTool AIMUX_E_NO_SUCH_TOOL (15) The model called a tool outside the supplied tool set; carries toolName and availableTools ([String]?, nil when no tool set was supplied)
.invalidToolInput AIMUX_E_INVALID_TOOL_INPUT (16) Tool arguments failed to parse/validate; carries toolName and toolInput (the raw argument text)
.toolCallRepair AIMUX_E_TOOL_CALL_REPAIR (17) A repairToolCall hook itself failed; carries originalError (the repaired-over error as AiMuxError JSON tagged by name, the ToolCall.error encoding)
.noOutputGenerated AIMUX_E_NO_OUTPUT_GENERATED (18) No output was generated
.loadApiKey AIMUX_E_LOAD_API_KEY (19) No API key passed and the fallback environment variable is unset; carries envVar
.loadSetting AIMUX_E_LOAD_SETTING (20) A required provider setting is missing; carries envVar
.other AIMUX_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:responseBody:url:requestBodyValues:responseHeaders:providerData:) (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 retryable. Six cases carry a typed payload as extra associated values: .apiCall(providerCode:providerMessage:requestId:responseBody:) (all optional), .noSuchModel(modelId:modelType:), .noSuchProvider(providerId:), .noSuchTool(toolName:availableTools:), .invalidToolInput(toolName:toolInput:) and .toolCallRepair(originalError:); the same-named computed properties return nil on every other case (toolName answers for both .noSuchTool and .invalidToolInput). 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 RecordingError — struct 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.

Failure Swift
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/generated/Types.swift is generated by aimux-codegen from the Rust types (do not edit it; run cargo run -p aimux-codegen). Its Codable types decode and encode exactly the JSON of the generated bindings/node/src/generated/types.ts (camelCase fields, unions tagged by type) and take the Rust type names — usable with the JSON-string APIs: for example JSONValue (recursive JSON enum), GenerateTextOptions, GenerateTextResult, GenerateObjectResult, ModelMessage, ModelPrompt, ContentPart, Tool, ToolChoice, ToolCall, RawToolCall, ToolResult, FileBytes, FileData, FilePartData (the AI SDK FilePart.data), GeneratedFile, Source, GenerateContent, LanguageModelUsage, FinishReasonUnified, Warning, TimeoutConfiguration, TextStreamPart, the OpenAI-shaped ChatCompletion (vendor shape, snake_case), and the multimodal results (EmbeddingResult, UploadFileResult, ...). Structs and associated-value enums conform to Sendable and are Codable, Equatable.

ToolCall, RawToolCall and Source encode their AI SDK type tag ("tool-call", "source") wherever they stand, as the AI SDK's TypedToolCall and Source do in a result's toolCalls and sources; decoding accepts JSON without the tag and rejects a different one.

ToolCall (top-level and TextStreamPart.toolCall) carries providerMetadata plus invalid (set by Core when tool lookup, input parse, or schema validation fails, even after repair) and error (the AiMuxError object for that failure, tagged by name, e.g. "AI_InvalidToolInputError"; held as a JSONValue, as is TextStreamPart.error).

Key order: the AI SDK's provider options, provider metadata and provider references are JS objects, which keep their keys in insertion order, and the JSON strings the Swift APIs return keep that order. The typed values do not: ProviderOptions, ProviderMetadata, UploadFileResult.providerReference and JSONValue.object are Swift Dictionary values, so a value decoded and encoded again may list its keys in another order. Pass the JSON string through unchanged when the order matters.

Example:

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

Coverage

Text generation and streaming have typed wrappers. The multimodal features (embedding, TTS, STT, image, video, rerank, search, files) take their options as JSON strings and return JSON strings that decode into the typed result structs above; see the coverage matrix.