n4nAI

Async/await for LLM API calls in Swift

Learn how to implement swift async await llm api calls in iOS apps with a practical step-by-step guide covering client setup, streaming, and error handling.

n4n Team3 min read681 words

Audio narration

Coming soon — every post will get a voice note here.

Calling an LLM from an iOS app shouldn’t require callback pyramids or third-party reactive wrappers. With swift async await llm api patterns, you can write straight-line code that handles network latency, retries, and streaming without losing your place. This guide walks through a production-shaped client you can drop into an iOS target today.

Step 1: Model the request and response

Start with the shapes your HTTP layer will encode and decode. The OpenAI chat completions schema is stable and supported by most gateways, so it’s a safe baseline.

import Foundation

struct ChatMessage: Codable {
    let role: String
    let content: String
}

struct ChatRequest: Codable {
    let model: String
    let messages: [ChatMessage]
    let stream: Bool
}

struct ChatChoice: Codable {
    let message: ChatMessage?
    let delta: ChatMessage? // present when streaming
}

struct ChatResponse: Codable {
    let id: String
    let choices: [ChatChoice]
}

Keep these plain Codable structs. Avoid nesting provider-specific fields unless you actually need them; a swift async await llm api client stays maintainable when it is decoupled from any single vendor’s extensions. If you later add tool calls or usage fields, extend the structs rather than rewriting the transport.

Step 2: Write a baseline async request function

URLSession ships native async methods on iOS 15 and later. Use data(for:) to send a request and decode a typed response in one pass.

func sendNonStreaming(request: ChatRequest, url: URL, apiKey: String) async throws -> ChatResponse {
    var req = URLRequest(url: url)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    req.httpBody = try JSONEncoder().encode(request)

    let (data, response) = try await URLSession.shared.data(for: req)
    guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(ChatResponse.self, from: data)
}

That is the entire non-streaming path. No delegates, no Result nesting. The function throws on transport errors, non-2xx status, or decode failure, and the caller decides how to recover.

Step 3: Choose an endpoint and configure the client

Point the url at any OpenAI-compatible /v1/chat/completions route. If you want a single endpoint that fronts 240+ models with automatic fallback when a provider is rate-limited or degraded, a gateway like n4n.ai exposes that route, honors client routing directives, forwards provider cache-control hints, and returns per-token usage metering in the response. Your swift async await llm api code does not change when you swap the underlying model provider.

let endpoint = URL(string: "https://api.n4n.ai/v1/chat/completions")!

Store the API key in the Keychain, not in source. For this how-to we pass it as a parameter so the snippets stay runnable.

Step 4: Add token streaming with AsyncThrowingStream

Streaming is where async/await earns its keep. Use URLSession.shared.bytes(for:) and iterate the response line by line, parsing Server-Sent Events.

func streamTokens(request: ChatRequest, url: URL, apiKey: String) -> AsyncThrowingStream<String, Error> {
    AsyncThrowingStream { continuation in
        let task = Task {
            do {
                var req = URLRequest(url: url)
                req.httpMethod = "POST"
                req.setValue("application/json", forHTTPHeaderField: "Content-Type")
                req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
                req.httpBody = try JSONEncoder().encode(request)

                let (bytes, response) = try await URLSession.shared.bytes(for: req)
                guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
                    continuation.finish(throwing: URLError(.badServerResponse))
                    return
                }

                for try await line in bytes.lines {
                    if line.hasPrefix("data: ") {
                        let payload = String(line.dropFirst(6))
                        if payload == "[DONE]" { continuation.finish(); return }
                        if let data = payload.data(using: .utf8),
                           let chunk = try? JSONDecoder().decode(ChatResponse.self, from: data),
                           let delta = chunk.choices.first?.delta?.content {
                            continuation.yield(delta)
                        }
                    }
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
        continuation.onTermination = { _ in task.cancel() }
    }
}

The AsyncThrowingStream bridges callback-style SSE into a for try await loop on the caller side. Cancellation propagates because we tie onTermination to task.cancel(). One caveat: bytes.lines splits on Unix newlines. If a provider emits bare CR, normalize the line buffer before parsing.

Step 5: Handle errors and retries without blocking

Networks fail. Wrap the non-streaming call in a bounded retry that respects cancellation and avoids retrying permanent decode errors.

func sendWithRetry(request: ChatRequest, url: URL, apiKey: String, maxAttempts: Int = 3) async throws -> ChatResponse {
    var attempt = 0
    while true {
        do {
            return try await sendNonStreaming(request: request, url: url, apiKey: apiKey)
        } catch {
            attempt += 1
            if attempt >= maxAttempts || error is DecodingError { throw error }
            try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt)) * 100_000_000))
        }
    }
}

For streaming, surface errors through the stream’s throw rather than swallowing them. A swift async await llm api integration that hides parse failures will silently truncate responses and confuse your UI. If you need retry on streams, recreate the AsyncThrowingStream from the top after a clean Task cancellation.

Step 6: Wire into a SwiftUI service layer

Expose the client through an ObservableObject so views bind to partial text as tokens arrive.

@MainActor
final class ChatViewModel: ObservableObject {
    @Published var output = ""
    private let url: URL
    private let apiKey: String

    init(url: URL, apiKey: String) {
        self.url = url
        self.apiKey = apiKey
    }

    func run(messages: [ChatMessage]) async {
        output = ""
        let req = ChatRequest(model: "gpt-4o-mini", messages: messages, stream: true)
        do {
            for try await token in streamTokens(request: req, url: url, apiKey: apiKey) {
                output += token
            }
        } catch {
            output = "Error: \(error.localizedDescription)"
        }
    }
}

Mark the class @MainActor so published updates hit the main thread. The for try await loop reads like synchronous concatenation but runs concurrently and yields control between tokens. If you call this from a button, wrap it in Task { await viewModel.run(...) }.

Step 7: Verify success

You need proof the client speaks the real protocol. Two concrete checks:

  1. Curl parity. Run a known-good curl request and confirm the JSON shape decodes into ChatResponse.
curl https://api.n4n.ai/v1/chat/completions \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":false}'

If the returned JSON maps cleanly, your Step 2 decoder is correct.

  1. In-app smoke test with a local mock. Stand up a minimal SSE server so CI doesn’t burn tokens.
# minimal sse mock
import http.server, json
class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        self.send_response(200)
        self.send_header("Content-Type","text/event-stream")
        self.end_headers()
        for t in ["Hello", " world"]:
            self.wfile.write(f"data: {{\"choices\":[{{\"delta\":{{\"content\":\"{t}\"}}}}]}}\n\n".encode())
        self.wfile.write(b"data: [DONE]\n\n")
http.server.HTTPServer(("127.0.0.1",8080),H).serve_forever()

Point endpoint at http://127.0.0.1:8080, call ChatViewModel.run with one message, and assert output equals “Hello world” after the stream terminates. This validates the swift async await llm api streaming path end to end with zero external dependencies.

Step 8: Production caveats

URLSession.shared is fine for foreground calls. For background LLM syncs, create a session with URLSessionConfiguration.background(withIdentifier:) and handle the delegate callbacks—async/await alone will not resume your app from suspension. Keep your ChatRequest immutable and your client stateless; own URLSession inside an actor or view model so Swift 6 strict concurrency does not flag shared mutable state.

If you adopt a gateway that meters per-token usage, parse the usage field or response headers in a thin extension to ChatResponse rather than threading billing logic through the transport. The swift async await llm api code stays clean when the transport only moves bytes and the caller interprets policy.

Tagsswiftasync-awaitiosllm-client

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All swift & ios llm integration posts →