Skip to main content

Chat Completions

All ExpressAI functionality is reached through the ExpressAi object returned by sdkManager.expressAi(). The call validates the ExpressAI license category and throws if the feature is not licensed; the returned object can be reused for the lifetime of the SdkManager (reusing it also reuses the cached enclave session).

info

The examples on this page use the async SDK flavor: suspend functions in Kotlin and async functions in Swift, all carrying the Async suffix. In the synchronous SDK flavor the same methods exist without the suffix (e.g. startChatCompletion) as blocking calls. On Apple platforms, blocking SDK calls must run on a thread with an enlarged stack — see the SDK example app's ExpressAiExample.swift for the pattern.

Resolving the subscription​

Both startChatCompletion and fetchChatCredits take a subscriptionId that must hold the AI entitlement. The SDK does not pick one for you — resolve it via the Subscriptions module and pass it explicitly:

guard let subscriptionId = try await sdkManager.subscriptions()
.getSubscriptionIdsForEntitlementsAsync(entitlementNames: .ai)
.first
else {
// No subscription with an AI entitlement — hide the AI feature
return
}

Building a request​

A request is the model code plus the conversation so far:

FieldTypeDescription
modelStringShort ExpressAI model code (e.g. oai). The set of available codes is defined by ExpressAI — keep it configurable rather than hardcoded.
messagesList<ChatMessage>The full conversation history, oldest first. Each ChatMessage has a role (System, User, Assistant, Tool) and plaintext content.

The service is stateless per request: to continue a conversation, send the previous messages (including the assistant's answers) plus the new user message.

Streaming a completion​

startChatCompletion performs the one-time setup (attestation, session, initial request) and returns an ExpressAiChatStream. Poll it with next() — each call yields either a Completion chunk or Done when the stream has ended cleanly. Failures are thrown from next(), never delivered as chunks, so Completion is always real content.

let expressAi = try sdkManager.expressAi()

let request = ExpressAiChatRequest(
model: "oai",
messages: [
ChatMessage(role: .system, content: "You are a helpful assistant."),
ChatMessage(role: .user, content: "Why is the sky blue?"),
]
)

let stream = try await expressAi.startChatCompletionAsync(
subscriptionId: subscriptionId,
request: request
)

var answer = ""
streamLoop: while true {
switch try await stream.nextAsync() {
case let .completion(deltaText, finishReason, _):
if let deltaText {
answer += deltaText // append to the UI as it arrives
}
if finishReason == .length || finishReason == .contentFilter {
// The answer is incomplete — surface that to the user
}
case .done:
break streamLoop
}
}

Completion chunk fields​

Each Completion is one decrypted OpenAI-style chat.completion.chunk, with the two fields nearly every consumer needs pre-extracted:

FieldTypeDescription
deltaTextString?Incremental assistant text for this chunk. Can be absent (e.g. role-only or usage-only chunks) — skip those.
finishReasonExpressAiFinishReason?Set on the terminal chunk only. Stop is a normal end; Length and ContentFilter mean the answer is incomplete.
chunkJsonStringThe full decrypted chunk as JSON, for anything beyond text (tool calls, usage, multiple choices, …).

Cancelling​

Call cancel() on the stream to stop the underlying request — for example when the user taps a stop button or leaves the screen. It is idempotent; the following next() returns Done. Releasing/dropping the stream object also stops it.

Reading the full response in one call​

If you only want the final answer and no incremental tokens, drain the stream with readAllMessages instead of a next() loop. It concatenates every chunk's text and returns it together with the terminal finishReason. The optional timeout bounds the total drain (default: 120 seconds); on expiry the stream is cancelled and an error is thrown.

The returned text is never a silently cut-off answer. The service ends a completion either with the [DONE] marker or with a finishReason on the last chunk; if the stream closes (or is cancelled) with neither, readAllMessages throws ExpressAiTruncatedStream instead of returning the partial text. finishReason is null only when the service sent [DONE] without one.

let stream = try await expressAi.startChatCompletionAsync(
subscriptionId: subscriptionId,
request: request
)
let response = try await stream.readAllMessagesAsync(timeout: nil)
print(response.text) // response.finishReason as above

Chat credits​

Completions consume credits from the subscription's balance. fetchChatCredits returns the current balance — for example to show it in the UI and refresh it after each completed stream:

let credits = try await expressAi.fetchChatCreditsAsync(subscriptionId: subscriptionId)
print("Credits: \(credits.remaining) / \(credits.allowance)")
FieldTypeDescription
remainingInt64 (Kotlin: Long)Credits still available in the current period. Can go negative if the balance was overdrawn.
allowanceInt64 (Kotlin: Long)Total credits granted for the period.

Error handling​

All ExpressAI methods throw ExpressAiException, a thin wrapper carrying the typed SdkException as its ExceptionDetails.source. Unwrap it and branch on the inner case:

SdkException caseMeaning / recommended handling
ExpressAiAuthExpressAI rejected the request token even after a refresh — the user's session has likely expired. Prompt a re-login, then retry.
ExpressAiApiAn in-band API error (carries an optional code and a reason), e.g. no credits left or an invalid model code.
ExpressAiTransportNetwork/transport failure, including a readAllMessages timeout. Usually worth a retry.
ExpressAiDecryptionA response chunk could not be decrypted.
ExpressAiProtocolThe service answered with an unexpected shape.
ExpressAiUnexpectedFinishThe model reported that the answer is incomplete (finishReason of length or content_filter) where a complete one was required, e.g. by Speedtest.explainRankingWithAi.
ExpressAiTruncatedStreamThe stream was cut: readAllMessages saw neither [DONE] nor a finishReason, so the text is a prefix of the real answer. The reason names how the stream ended, the character count and the tail of the text. Usually worth a retry.
InvalidConfigurationThe express_ai config block is missing — see Enabling & Configuring.
InvalidInputParametersAn empty subscriptionId was passed.
do {
let stream = try await expressAi.startChatCompletionAsync(
subscriptionId: subscriptionId,
request: request
)
// ... consume the stream, next() throws the same way ...
} catch let ExpressAiException.ExceptionDetails(source) {
switch source {
case .ExpressAiAuth:
// Session/token rejected — prompt a re-login
break
case let .ExpressAiApi(code, reason):
print("ExpressAI API error \(code ?? "-"): \(reason)")
default:
// Transport / decryption / protocol — surface a generic failure
break
}
}