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:
- 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.
- 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.