aimux

August 22, 2026 · View on GitHub

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

The Go binding goes through the aimux-ffi C ABI: cgo statically links libaimux_ffi.a, so the Rust core is compiled into the executable and the result is a single binary. See RFC-0011 for the design.

Shared reference — parameter tables, result shapes, factory functions, and the feature coverage matrix — lives in the API overview.

Install

go get github.com/arcships/aimux/bindings/go
go generate github.com/arcships/aimux/bindings/go   # downloads libaimux_ffi.a for your platform
go build ./...

Details (version pinning, unsupported platforms): bindings/go/README.md.

Requires Go 1.23+.

Errors

Go follows openai-go / anthropic-sdk-go: one *aimux.Error struct implementing error (not a class tree). Inspect with errors.As:

result, err := model.GenerateText(`"hi"`, "")
if err != nil {
    var e *aimux.Error
    if errors.As(err, &e) {
        // e.Code, e.Message, e.Status, e.RetryMs
        if e.Code == aimux.CodeAPICall { // every HTTP-shaped failure
            switch e.Status {
            case 429:
                // rate limited; e.RetryMs may be a delay hint
            case 401:
                // auth failure
            case 404:
                // model not found
            }
        }
    }
    return err
}
FieldMeaning
Codeerror code (CodeAPICall, CodeTokenExpired, …; matches C aimux_error_code_t)
Messagehuman-readable text
StatusHTTP status, or -1
RetryMsrate-limit hint, or -1 (0 = retry now)
Retryablethe core's retry verdict; never derived from Status
ProviderCode, ProviderMessage, RequestID, ResponseBodyCodeAPICall payload; empty under any other code
ModelID, ModelTypeCodeNoSuchModel payload; empty under any other code
ProviderIDCodeNoSuchProvider payload; empty under any other code

Code values 1..13 mirror aimux-core's AiMuxError variants. A code outside the enum is a header/library mismatch and fails with a panic, not an error type.

Recording failures are a separate type, as in Rust (recording::RecordingError is unrelated to AiMuxError): RecordingTryFlush() error returns *aimux.RecordingError{Code RecordingErrorCode, Message string}, with Code one of RecordingErrorInit … RecordingErrorWrite (matches C aimux_error_code_t; only WriterGone, FlushTimeout, Write are reachable from a flush). InitRecording(dir) error reports construction failures (Init, OpenFile, Spawn) as the same *aimux.RecordingError; on failure the previous recorder stays in place. Inspect with errors.As(err, &re). It is not an *aimux.Error; its Code belongs to RecordingErrorCode, not the core aimux.Code enum. The legacy RecordingFlush() stays and never reports.

The two types — *Error, *RecordingError — share no base beyond error; errors.As for one never matches another. There is no third aimux type.

C ABI failures ("your call, not the model") have codes 200–206 in C but no Go type of their own; the binding maps them to native Go errors:

FailureGo
bad raw JSON in promptJson / optsJson / configJSONplain error naming the parameter, e.g. aimux: prompt_json: invalid JSON — checked in Go before the C call with json.Valid plus a surrogate-pairing scan, because json.Valid accepts unpaired \uD800\uDFFF escapes that serde_json rejects (required parameters reject ""; optional "" = default; JSONL by line)
a string parameter that is not valid UTF-8, or contains a NULplain error: aimux: <param>: must be valid UTF-8 / aimux: <param>: must not contain NUL — a Go string is an arbitrary byte sequence and C.CString passes it through verbatim (a NUL would truncate the argument silently), so every user-supplied string is checked before the C call. Two exceptions, both deliberate: the five mustNew constructors panic instead (see below), and InitLogging has no error channel and falls back to "warn"
a typed option struct (ProviderConfig, EmbeddingCallOptions, SpeechCallOptions, … , TranscriptionSessionOpts) whose raw JSON field carries bad bytesplain error naming the marshalled parameter, e.g. aimux: opts: invalid JSON: unpaired high surrogate \uD800, aimux: config_json: must be valid UTF-8json.Marshal is not self-validating: it coerces invalid UTF-8 in a Go string field to U+FFFD and escapes NUL, but a json.RawMessage field (ProviderOptions, BodyOverrides, Audio, Documents, Files, Mask, Data, InputSchema) is emitted through compact(), which checks JSON syntax only. Raw non-UTF-8 bytes and lone \uD800\uDFFF escapes therefore survive marshalling, so every marshalled C argument gets the same checkJSON as a raw-string parameter
use-after-close (Model, ProviderHandle, multimodal models, TranscriptionSession)errors.Is(err, aimux.ErrClosed) — guarded in Go before the C call
a nil *Model handed to a composite constructor — a NewRouter child, a NewMoa reference, or the NewMoa aggregatorplain error naming the position: aimux: router: models[2] is nil, aimux: moa: references[1] is nil, aimux: moa: aggregator is nil — checked in Go before the C call, so a nil element is a returned error rather than the nil-pointer dereference that would otherwise take the process down
trace query (TraceAggregate, TraceSessionChain, TraceExportJsonl, TraceClear) on a model that never went through Trace / TraceAuditederrors.Is(err, aimux.ErrNotTraced) — guarded in Go before the C call, ahead of argument validation; the trace store is keyed on the wrapper handle, so C can only report it as a missing handle
C code 200–206 (NULL / non-UTF-8 argument, malformed wire JSON, dead handle, re-entrant call, result serialization, callback failure)returned as a plain error (not *Error, not *RecordingError, not ErrClosed): aimux: <the C message>

Decoder: every fallible C call returns an opaque aimux_error_t * (NULL = success, result in the out-parameter). One aimux_error_code() distinguishes AiMuxError (1–13), RecordingError (100–105), and C ABI failures (200–206). expectAimuxError, expectRecordingError, and expectFfiError enforce the range expected by each call; the first two restore *Error and *RecordingError, while 200–206 becomes a plain error. Every path frees the getter strings and calls aimux_error_free exactly once; errors are never handles. Nothing of this leaks into the Go API.

The binding has five panic sites. Four are unreachable from Go input; the fifth is the documented Must-style API, which panics on invalid input by design — that one is opt-in, and every one of its five entry points has a NewXxx twin that returns the same failure as an error instead.

Panic siteReachable from Go input?
aimux.go mustNew — behind OpenAI / OpenAIWithBase / Anthropic / AnthropicWithBase / DeepSeekYes, by design. regexp.MustCompile convention: an apiKey / modelID / baseURL that is not valid UTF-8 or contains a NUL panics, as does any AiMuxError failure. Use NewOpenAI / NewOpenAIWithBase / NewAnthropic / NewAnthropicWithBase / NewDeepSeek for anything caller-supplied
aimux.go InitLoggingexpectFfiError returned an errorNo. level is coerced first: empty, non-UTF-8, or NUL-bearing falls back to "warn", which is what aimux-core does with an unparseable level anyway (AIMUX_LOG / AIMUX_LOG_LEVEL outrank it regardless). That leaves no documented failure for aimux_init_logging, so a non-nil error here is a header/library mismatch
aimux.go expectAimuxErroraimux_error_code_t outside 1..13No. Header/library version mismatch
aimux.go expectRecordingErroraimux_error_code_t outside the enumNo. Header/library version mismatch
multimodal.go TranscriptionSession.NextPart — unknown aimux_transcription_next_part stateNo. Header/library version mismatch

The three mismatch panics are a contract violation the C header itself says to abort on, not an error to report.

Quick Start

// cgo statically links libaimux_ffi.a, producing a single binary (the Rust core is compiled into the executable)
model := aimux.OpenAIWithBase("sk-...", "gpt-4o", "http://localhost:3000")
defer model.Close()
result, err := model.GenerateText(`"What is Rust?"`, "")
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)
// streaming (typed: model.Stream(prompt, opts) yields *StreamPart values)
stream := model.StreamText(`"Write a haiku"`, "")
for part := range stream.Parts() {
    fmt.Println(part) // StreamPart JSON
}
if err := stream.Err(); err != nil { // only after Parts has closed
    log.Fatal(err)
}

Drain Parts() completely, or call Cancel() when stopping early. Context variants connect cancellation automatically. Cancel releases callback backpressure and aborts the native request; it never closes Parts() itself. The producer closes Parts() after the blocking native call returns.

Providers

All 251 registry-backed OpenAI-compatible providers are reachable by name; aimux.ProviderName holds typed constants:

Scope: provider(name) covers only the 251 registry OpenAI-compatible providers; Anthropic/Google/multimodal/local → typed constructors (NewAnthropic(apiKey, model)); custom endpoints → WithBase variant. Full list: providers.md.

// 推荐:aimux.Groq 类型常量(类型检查 + 补全)
model, err := aimux.Provider(string(aimux.Groq), "", "llama-3.3-70b")
if err != nil { log.Fatal(err) }
defer model.Close()

// 字符串形式同样可用 + base URL 覆盖:
model2, err := aimux.ProviderWithBase("groq", "sk-...", "llama-3.3-70b", "https://relay.example/v1")
defer model2.Close()

// 完整 ProviderOptions(base_url / headers / organization / project /
// max_retries / body_overrides):
model3, err := aimux.ProviderWithConfig("groq", "sk-...", "llama-3.3-70b", &aimux.ProviderConfig{
	Headers: map[string]string{"X-Custom": "1"},
})
defer model3.Close()

NewDeepSeek / DeepSeek remain as shortcuts (registry-backed). Unknown names return an error.

Text Generation

Non-streaming text generation; returns the complete result.

model := aimux.OpenAIWithBase("sk-...", "gpt-4o", "http://localhost:3000")
defer model.Close()
result, err := model.GenerateText(`"What is Rust?"`, "")
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

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

Streaming Generation

Returns generated content as a stream, output chunk by chunk.

stream := model.StreamText(`"Write a haiku"`, "")
defer stream.Cancel()

for part := range stream.Parts() {
    fmt.Println(part) // StreamPart JSON
}
if err := stream.Err(); err != nil {
    log.Fatal(err)
}

Stream part variants are documented in the API overview. Drain Parts() before calling Err(). If you stop reading early, call Cancel() so the native stream does not keep running.

Vector Embedding

Converts text into a vector representation.

// Typed API — Embed() takes []string, returns a JSON string you can parse
// with ParseEmbeddingResult.
embedder, err := aimux.NewOpenAIEmbedding("sk-...", "text-embedding-3-small")
if err != nil {
    log.Fatal(err)
}
defer embedder.Close()

resultJSON, err := embedder.Embed([]string{"hello", "world"}, nil)
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseEmbeddingResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
fmt.Println(len(result.Embeddings))       // 2
fmt.Println(len(result.Embeddings[0]))    // 1536 (dimension depends on model)

Speech Synthesis (TTS)

Converts text into speech audio.

voice := "alloy"
outputFormat := "mp3"

speaker, err := aimux.NewOpenAISpeech("sk-...", "tts-1")
if err != nil {
    log.Fatal(err)
}
defer speaker.Close()

resultJSON, err := speaker.Generate(&aimux.SpeechCallOptions{
    Text:         "Hello world!",
    Voice:        &voice,    // optional *string fields — pass a pointer or nil
    OutputFormat: &outputFormat,
})
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseSpeechResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
// audio bytes: *result.Audio.Base64 (base64 string) or result.Audio.Binary

Speech to Text (STT)

Converts audio into text (non-streaming).

transcriber, err := aimux.NewOpenAITranscription("sk-...", "whisper-1")
if err != nil {
    log.Fatal(err)
}
defer transcriber.Close()

// audioBase64 is base64-encoded audio; media type e.g. "audio/mp3"
resultJSON, err := transcriber.Generate(audioBase64, "audio/mp3", nil)
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseTranscriptionResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result.Text)              // transcribed text
fmt.Println(result.Segments)          // timestamped segments
fmt.Println(*result.Language)         // detected language

Image Generation

prompt := "A cute baby sea otter"
n := 1

imager, err := aimux.NewOpenAIImage("sk-...", "dall-e-3")
if err != nil {
    log.Fatal(err)
}
defer imager.Close()

resultJSON, err := imager.Generate(&aimux.ImageCallOptions{
    Prompt: &prompt,
    N:      &n,
})
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseImageResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
// result.Images.Base64[0] (base64) or result.Images.Binary[0] (raw bytes)

Video Generation

Video generation typically returns a URL (not binary).

prompt := "A cat playing piano"
n := 1

videor, err := aimux.NewGoogleVideo("sk-...", "veo-3.0")
if err != nil {
    log.Fatal(err)
}
defer videor.Close()

resultJSON, err := videor.Generate(&aimux.VideoCallOptions{
    Prompt: &prompt,
    N:      &n,
})
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseVideoResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
// result.Videos[0].Url.URL — video URL

Reranking

Reorders a document list by relevance.

topN := 3

reranker, err := aimux.NewCohereReranking("sk-...", "rerank-v3.0")
if err != nil {
    log.Fatal(err)
}
defer reranker.Close()

resultJSON, err := reranker.Rerank(&aimux.RerankingCallOptions{
    Query:     "What is Rust?",
    Documents: json.RawMessage(`[{"text":"Rust is a systems programming language."},{"text":"Rust is a chemical element."}]`),
    TopN:      &topN,
})
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseRerankingResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
// result.Ranking sorted by relevance score
for _, rank := range result.Ranking {
    fmt.Println(rank.Index, rank.RelevanceScore)
}

Calls a search provider to obtain results.

maxResults := 5

searcher, err := aimux.NewTavilySearch("tvly-...")
if err != nil {
    log.Fatal(err)
}
defer searcher.Close()

resultJSON, err := searcher.Search(&aimux.SearchCallOptions{
    Query:      "What is Rust?",
    MaxResults: &maxResults,
})
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseSearchResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
// result.Results is []SearchResultItem; result.Answer is *string (may be nil)
for _, item := range result.Results {
    fmt.Println(*item.Title, *item.URL)
}

File Upload

Uploads a file to the provider and returns a file ID.

files, err := aimux.NewOpenAIFiles("sk-...")
if err != nil {
    log.Fatal(err)
}
defer files.Close()

// dataBase64 is base64-encoded file content; media type e.g. "application/pdf"
resultJSON, err := files.Upload(dataBase64, "application/pdf", nil)
if err != nil {
    log.Fatal(err)
}
result, err := aimux.ParseUploadFileResult(resultJSON)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result.ProviderReference)  // map["openai":"file-xxx"]

API Surface

All constructors come in two flavors: NewXxx(...) (T, error) (checked) and Xxx(...) T (unchecked, panics on failure). Every model type has a Close() method that atomically drops the underlying FFI handle. Handle wrappers must not be copied after first use. Close is idempotent and does not wait for an in-flight network call or stream; a racing call either enters Rust first and continues with its cloned Arc, or receives an invalid-handle error.

Constructors

ConstructorReturnsNotes
NewOpenAI / NewOpenAIWithBase*Modelplus OpenAI / OpenAIWithBase, which panic on any failure, invalid input included
NewAnthropic / NewAnthropicWithBase*Modelplus Anthropic / AnthropicWithBase, which panic on any failure, invalid input included
NewDeepSeek*ModelDeepSeek uses its official base URL; DeepSeek is the panicking twin
NewOpenAIEmbedding / NewCohereEmbedding / NewGoogleEmbedding (key, modelID)*EmbeddingModeleach has a …WithBase variant
NewOpenAISpeech(key, modelID)*SpeechModel
NewOpenAITranscription(key, modelID)*TranscriptionModel
NewOpenAIImage / NewGoogleImage (key, modelID)*ImageModeleach has a …WithBase variant
NewGoogleVideo(key, modelID)*VideoModel
NewCohereReranking(key, modelID)*RerankingModel
NewTavilySearch(key)*SearchModelno model ID needed
NewOpenAIFiles(key)*Files
NewRouter(models []*Model, configJSON)*ModelRFC-0021 fallback router over models (must be non-empty); the same model may appear more than once
NewMoa(references []*Model, aggregator *Model, configJSON)*ModelRFC-0022 mixture-of-agents; references may be empty, may repeat a model, and may contain the aggregator

Composite constructors and concurrency

NewRouter and NewMoa snapshot each atomic handle in caller order and never hold a Go lifecycle lock while calling C. Duplicate models require no special case, and opposite caller orders cannot form an ABBA cycle because there is no multi-lock protocol. Concurrent Close is resolved by the Rust registry: construction either clones a model Arc or returns an invalid-handle error.

  • A nil element is a returned error, checked before the C call: aimux: router: models[i] is nil, aimux: moa: references[i] is nil, aimux: moa: aggregator is nil.

Both constructors take a new reference to each child, so the caller keeps ownership: closing a child afterwards does not invalidate the composite, and the composite must be closed separately.

Methods

TypeMethodsResult
*ModelGenerateText(promptJson, optsJson) (string, error) — raw JSON; Generate(prompt any, opts *GenerateTextOptions) (*GenerateTextResult, error) — typedtyped input via Generate
*ModelStreamText(promptJson, optsJson) *Stream — raw JSON parts; Stream(prompt any, opts *GenerateTextOptions) (*TypedStream, error) — typed *StreamPart valuesStream.Parts() / TypedStream.Parts() channels, .Err()
*EmbeddingModelEmbed(values []string, opts *EmbeddingCallOptions) (string, error)returns JSON; use ParseEmbeddingResult
*SpeechModelGenerate(opts *SpeechCallOptions) (string, error)ParseSpeechResult
*ImageModelGenerate(opts *ImageCallOptions) (string, error)ParseImageResult
*TranscriptionModelGenerate(audioBase64, mediaType string, opts *TranscriptionCallOptions) (string, error)ParseTranscriptionResult
*VideoModelGenerate(opts *VideoCallOptions) (string, error)ParseVideoResult
*RerankingModelRerank(opts *RerankingCallOptions) (string, error)ParseRerankingResult
*SearchModelSearch(opts *SearchCallOptions) (string, error)ParseSearchResult
*FilesUpload(dataBase64, mediaType string, opts *UploadFileCallOptions) (string, error)ParseUploadFileResult

opts is required (non-nil) for SpeechModel.Generate, ImageModel.Generate, VideoModel.Generate, Rerank and Search — it carries the input; nil returns aimux: <Method>: opts is required. Embed, TranscriptionModel.Generate and Upload accept nil opts (defaults). InitProxy requires non-empty JSON ("{}" for defaults); TraceAggregate("") means "all". The trace queries (TraceAggregate, TraceSessionChain, TraceExportJsonl, TraceClear) run only on the *Model returned by Trace() / TraceAudited(); on any other model they return aimux.ErrNotTraced.

Types

Typed structs live in bindings/go/types.go (text) and bindings/go/multimodal_types.go (multimodal): GenerateTextOptions, GenerateTextResult, StreamPart, ModelMessage, Tool, ToolChoice (with helpers ToolChoiceAuto() / ToolChoiceNone()), ToolCall, ToolResult, Usage, FinishReason, Role, MessageContent, ContentPart, ResponseFormat, ReasoningEffort, Warning, GenerateResult, plus EmbeddingCallOptions/Result, SpeechCallOptions/Result, ImageCallOptions/Result, TranscriptionCallOptions/Result, VideoCallOptions/Result, RerankingCallOptions/Result, SearchCallOptions/Result, UploadFileCallOptions/Result.

The multimodal methods return JSON strings through the C ABI; the ParseXxxResult functions decode them into the typed structs. All call-option pointer fields (*string, *bool, *int) are optional — pass nil to omit.