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).
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:
- Swift
- Kotlin
guard let subscriptionId = try await sdkManager.subscriptions()
.getSubscriptionIdsForEntitlementsAsync(entitlementNames: .ai)
.first
else {
// No subscription with an AI entitlement — hide the AI feature
return
}
val subscriptionId = sdkManager.subscriptions()
.getSubscriptionIdsForEntitlementsAsync(EntitlementProduct.Ai)
.firstOrNull()
?: return // No subscription with an AI entitlement — hide the AI feature
Building a request
A request is the model code plus the conversation so far:
| Field | Type | Description |
|---|---|---|
model | String | Short ExpressAI model code (e.g. oai). The set of available codes is defined by ExpressAI — keep it configurable rather than hardcoded. |
messages | List<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.
- Swift
- Kotlin
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
}
}
val expressAi = sdkManager.expressAi()
val request = ExpressAiChatRequest(
model = "oai",
messages = listOf(
ChatMessage(role = ExpressAiChatRole.System, content = "You are a helpful assistant."),
ChatMessage(role = ExpressAiChatRole.User, content = "Why is the sky blue?"),
),
)
val stream = expressAi.startChatCompletionAsync(subscriptionId, request)
val answer = StringBuilder()
loop@ while (true) {
when (val message = stream.nextAsync()) {
is ExpressAiStreamMessage.Completion -> {
message.deltaText?.let { answer.append(it) } // append to the UI as it arrives
when (message.finishReason) {
is ExpressAiFinishReason.Length,
is ExpressAiFinishReason.ContentFilter -> {
// The answer is incomplete — surface that to the user
}
else -> Unit
}
}
is ExpressAiStreamMessage.Done -> break@loop
}
}
Completion chunk fields
Each Completion is one decrypted OpenAI-style chat.completion.chunk, with the two fields nearly every consumer needs pre-extracted:
| Field | Type | Description |
|---|---|---|
deltaText | String? | Incremental assistant text for this chunk. Can be absent (e.g. role-only or usage-only chunks) — skip those. |
finishReason | ExpressAiFinishReason? | Set on the terminal chunk only. Stop is a normal end; Length and ContentFilter mean the answer is incomplete. |
chunkJson | String | The 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.
- Swift
- Kotlin
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
val stream = expressAi.startChatCompletionAsync(subscriptionId, request)
val response = stream.readAllMessagesAsync(timeout = null)
println(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:
- Swift
- Kotlin
let credits = try await expressAi.fetchChatCreditsAsync(subscriptionId: subscriptionId)
print("Credits: \(credits.remaining) / \(credits.allowance)")
val credits = expressAi.fetchChatCreditsAsync(subscriptionId)
println("Credits: ${credits.remaining} / ${credits.allowance}")
| Field | Type | Description |
|---|---|---|
remaining | Int64 (Kotlin: Long) | Credits still available in the current period. Can go negative if the balance was overdrawn. |
allowance | Int64 (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 case | Meaning / recommended handling |
|---|---|
ExpressAiAuth | ExpressAI rejected the request token even after a refresh — the user's session has likely expired. Prompt a re-login, then retry. |
ExpressAiApi | An in-band API error (carries an optional code and a reason), e.g. no credits left or an invalid model code. |
ExpressAiTransport | Network/transport failure, including a readAllMessages timeout. Usually worth a retry. |
ExpressAiDecryption | A response chunk could not be decrypted. |
ExpressAiProtocol | The service answered with an unexpected shape. |
ExpressAiUnexpectedFinish | The model reported that the answer is incomplete (finishReason of length or content_filter) where a complete one was required, e.g. by Speedtest.explainRankingWithAi. |
ExpressAiTruncatedStream | The 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. |
InvalidConfiguration | The express_ai config block is missing — see Enabling & Configuring. |
InvalidInputParameters | An empty subscriptionId was passed. |
- Swift
- Kotlin
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
}
}
try {
val stream = expressAi.startChatCompletionAsync(subscriptionId, request)
// ... consume the stream, next() throws the same way ...
} catch (e: ExpressAiException.ExceptionDetails) {
when (val source = e.source) {
is SdkException.ExpressAiAuth -> {
// Session/token rejected — prompt a re-login
}
is SdkException.ExpressAiApi ->
println("ExpressAI API error ${source.code ?: "-"}: ${source.reason}")
else -> {
// Transport / decryption / protocol — surface a generic failure
}
}
}