A swiftui chat interface llm api integration doesn’t require a heavyweight framework—just URLSession, async/await, and a disciplined message model. This tutorial builds a minimal but production-shaped iOS chat client that streams tokens from an OpenAI-compatible endpoint and renders them in real time.
Prerequisites
- Xcode 15+ and an iOS 17 target (async/await and
ObservableObjectavailable). - A basic SwiftUI app project (
@mainApp/WindowGroup). - An API key for an OpenAI-compatible LLM endpoint. For broad model access with automatic fallback, a gateway like n4n.ai exposes one endpoint that fronts 240+ models.
- Comfort with
URLSessionandAsyncSequence.
Data model
Start with a strict message type. Avoid stringly-typed roles.
import Foundation
enum Role: String, Codable {
case system, user, assistant
}
struct ChatMessage: Identifiable, Codable {
let id = UUID()
let role: Role
var content: String
}
Keep content mutable on the assistant side so you can append streamed tokens.
API request shape
The endpoint expects a standard chat completion payload. Here’s the JSON you’ll send:
{
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": "You are a concise helper."},
{"role": "user", "content": "Explain async/await in Swift."}
],
"stream": true
}
Set stream: true to receive Server-Sent Events (SSE). Each event is a JSON patch with choices[0].delta.content.
Building the streaming client
Use URLSession.bytes(for:) to get an async byte stream. Parse lines prefixed with data: .
final class LLMClient {
private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")!
private let apiKey: String
init(apiKey: String) { self.apiKey = apiKey }
func streamChat(messages: [ChatMessage]) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = [
"model": "gpt-4o-mini",
"messages": messages.map { ["role": $0.role.rawValue, "content": $0.content] },
"stream": true
] as [String: Any]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
continuation.finish(throwing: URLError(.badServerResponse))
return
}
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let payload = line.dropFirst(6)
if payload == "[DONE]" { continuation.finish(); break }
if let data = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let delta = choices.first?["delta"] as? [String: Any],
let token = delta["content"] as? String {
continuation.yield(token)
}
}
}
}
}
}
}
This client yields token strings. It does not manage conversation state—keep that in the view model.
View model
Use ObservableObject with @Published for simplicity. Store messages and a flag for in-flight requests.
import SwiftUI
final class ChatViewModel: ObservableObject {
@Published var messages: [ChatMessage] = []
@Published var inputText = ""
@Published var isLoading = false
private let client: LLMClient
init(client: LLMClient) { self.client = client }
func send() {
let userMsg = ChatMessage(role: .user, content: inputText)
messages.append(userMsg)
inputText = ""
isLoading = true
var assistantMsg = ChatMessage(role: .assistant, content: "")
messages.append(assistantMsg)
Task {
do {
for try await token in client.streamChat(messages: Array(messages.dropLast())) {
assistantMsg.content += token
if let idx = messages.firstIndex(where: { $0.id == assistantMsg.id }) {
messages[idx] = assistantMsg
}
}
} catch {
assistantMsg.content = "Error: \(error.localizedDescription)"
if let idx = messages.firstIndex(where: { $0.id == assistantMsg.id }) {
messages[idx] = assistantMsg
}
}
isLoading = false
}
}
}
We pass messages.dropLast() to avoid sending the empty assistant placeholder. In production, separate sendable history from UI state.
SwiftUI views
A List with ForEach plus a bottom input bar.
struct ChatView: View {
@StateObject var vm: ChatViewModel
var body: some View {
VStack {
List(vm.messages) { msg in
MessageRow(message: msg)
}
HStack {
TextField("Message", text: $vm.inputText)
.textFieldStyle(.roundedBorder)
Button(vm.isLoading ? "..." : "Send") {
vm.send()
}.disabled(vm.isLoading || vm.inputText.isEmpty)
}.padding()
}
}
}
struct MessageRow: View {
let message: ChatMessage
var body: some View {
HStack {
if message.role == .user { Spacer() }
Text(message.content.isEmpty ? "…" : message.content)
.padding(8)
.background(message.role == .user ? Color.blue.opacity(0.2) : Color.gray.opacity(0.2))
.cornerRadius(8)
if message.role == .assistant { Spacer() }
}
}
}
Wire it up in App
@main
struct LLMChatApp: App {
var body: some Scene {
WindowGroup {
let client = LLMClient(apiKey: ProcessInfo.processInfo.environment["LLM_API_KEY"] ?? "")
ChatView(vm: ChatViewModel(client: client))
}
}
}
Checkpoint: manual curl test
Before running the app, validate the endpoint with curl. Expected output is a stream of data: {...} lines.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $LLM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"stream":true}'
You should see lines like:
data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}
data: {"choices":[{"delta":{"content":" there"},"index":0}]}
data: [DONE]
Checkpoint: simulator run
Launch the app in the iOS Simulator. Type “What is Swift concurrency?” and tap Send. The assistant row updates token-by-token:
You: What is Swift concurrency?
Assistant: Swift concurrency is built on async/await, actors, and structured tasks…
If you see the full text appear at once, confirm stream: true is set and the line parser strips data: correctly.
Handling errors and rate limits
Providers return 429 when rate-limited. In the client, map non-200 to a typed error. If you use a gateway that honors client routing directives and provides automatic fallback when a provider is degraded, your LLMClient code stays unchanged—only the endpoint URL differs.
Add a simple retry wrapper if needed:
func streamChatWithRetry(messages: [ChatMessage], retries: Int = 2) -> AsyncThrowingStream<String, Error> {
// wrap streamChat; on URLError.timeout, recreate stream up to retries
}
Keep retries at the call site, not inside the byte loop, to avoid partial duplicates.
Production notes
- Persist messages with Core Data or SwiftData if you need history across launches.
- Sanitize user input before logging; never log the API key.
- For long conversations, trim messages to the last N tokens to respect context windows.
- Use
URLSessionConfiguration.ephemeralfor non-caching if privacy matters.
The swiftui chat interface llm api pattern above scales to multi-turn agents: swap the static system prompt for a dynamic one, and add tool-call deltas to the parser. The core—async byte streaming, immutable message IDs, and a single source of truth in the view model—doesn’t change.
Expected final structure
Your project should contain:
LLMClient.swift(networking)ChatViewModel.swift(state)ChatView.swift(UI)LLMChatApp.swift(entry)
Build and run. You now have a runnable swiftui chat interface llm api client that streams tokens with roughly 150 lines of real code.