Most iOS apps talk to language models over HTTP, but the response shape is easy to get wrong. This tutorial walks through swift codable llm api parsing using native Codable types, so you trade fragile dictionary lookups for compile-time safety. We’ll build a small but production-realistic client for an OpenAI-compatible chat endpoint and decode both full and streamed responses.
Prerequisites
- Xcode 15+ and Swift 5.9+ (or Swift 6).
- Comfort with
URLSessionand async/await. - An OpenAI-compatible endpoint. For local testing you can save the sample JSON to a file; for live calls any provider or gateway works. If you route through a gateway like n4n.ai, the response shape is identical to OpenAI’s because it exposes one OpenAI-compatible endpoint across 240+ models, so your structs stay the same.
- No third-party JSON libraries.
Foundationis enough.
The response shape
A non-streaming chat completion from an OpenAI-compatible API looks like this:
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1699000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help?"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 7,
"total_tokens": 19
}
}
The fields are stable, but note the snake_case keys (finish_reason, prompt_tokens). Swift convention is camelCase, so we need a mapping strategy.
Define Codable structs
You can either write explicit CodingKeys or set JSONDecoder().keyDecodingStrategy = .convertFromSnakeCase. For a small model, explicit keys are self-documenting. Here’s the full set:
struct ChatCompletion: Decodable {
let id: String
let object: String
let created: Int
let model: String
let choices: [Choice]
let usage: Usage
}
struct Choice: Decodable {
let index: Int
let message: Message
let finishReason: String?
enum CodingKeys: String, CodingKey {
case index
case message
case finishReason = "finish_reason"
}
}
struct Message: Decodable {
let role: String
let content: String
}
struct Usage: Decodable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
If you prefer less boilerplate, drop the CodingKeys and decode with:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
Either way, swift codable llm api parsing now gives you a typed ChatCompletion instead of [String: Any].
Decode from Data
Wrap decoding in a thin function. Never force-try in production; propagate errors.
func parseCompletion(_ data: Data) throws -> ChatCompletion {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(ChatCompletion.self, from: data)
}
Local test checkpoint
Save the earlier JSON to completion.json in your test target. Load and print:
let url = Bundle.module.url(forResource: "completion", withExtension: "json")!
let data = try Data(contentsOf: url)
let completion = try parseCompletion(data)
print("id: \(completion.id)")
print("content: \(completion.choices.first?.message.content ?? "")")
print("total tokens: \(completion.usage.totalTokens)")
Expected output:
id: chatcmpl-123
content: Hello! How can I help?
total tokens: 19
If you see a DecodingError.keyNotFound, check that your CodingKeys or strategy matches the JSON. That’s the most common failure in swift codable llm api parsing.
Handling partial and missing fields
Real APIs drift. A content field might be null for tool-call responses, or usage may be absent when the endpoint omits it. Make fields optional where the spec allows:
struct Message: Decodable {
let role: String
let content: String? // can be nil for function calls
}
And in ChatCompletion, mark usage as optional:
let usage: Usage?
Now a response without usage decodes cleanly. Write a second test JSON that omits usage and confirm no throw.
Streaming responses
LLM APIs stream incremental tokens via Server-Sent Events (SSE). Each event is a line data: {json} followed by a blank line. The JSON is a chunk with a delta instead of a full message:
{
"id": "chatcmpl-123",
"object": "chat.completion.chunk",
"created": 1699000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"delta": { "role": "assistant", "content": "Hello" },
"finish_reason": null
}
]
}
Define a chunk model:
struct ChatCompletionChunk: Decodable {
let id: String
let choices: [ChunkChoice]
}
struct ChunkChoice: Decodable {
let index: Int
let delta: Delta
let finishReason: String?
enum CodingKeys: String, CodingKey {
case index
case delta
case finishReason = "finish_reason"
}
}
struct Delta: Decodable {
let role: String?
let content: String?
}
Consuming the stream
Use URLSession.shared.bytes(from:) to read lines asynchronously. Filter SSE prefixes and stop on [DONE].
func streamCompletion(from url: URL) async throws {
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let payload = line.dropFirst(6)
if payload == "[DONE]" { break }
let chunkData = Data(payload.utf8)
let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: chunkData)
if let content = chunk.choices.first?.delta.content {
print(content, terminator: "")
}
}
print("")
}
Running this against a streaming endpoint prints the assistant message token by token. The same swift codable llm api parsing technique applies per chunk; you just decode a smaller struct.
Error handling in practice
Wrap network calls and decoding in a Result or throw. Distinguish transport errors from decoding errors:
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
let completion = try parseCompletion(data)
// use completion
} catch let DecodingError.keyNotFound(key, _) {
logger.error("Missing key: \(key.stringValue)")
} catch {
logger.error("Request failed: \(error.localizedDescription)")
}
Decoding errors often reveal API version mismatches. Log the raw JSON in debug builds to inspect.
A minimal client
Put it together as a struct you can call from a SwiftUI view or CLI:
struct LLMClient {
let baseURL: URL
let apiKey: String
func complete(prompt: String) async throws -> String {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/chat/completions"))
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = [
"model": "gpt-4o-mini",
"messages": [["role": "user", "content": prompt]]
] as [String: Any]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
let completion = try parseCompletion(data)
return completion.choices.first?.message.content ?? ""
}
}
This is deliberately minimal. For real apps, add retry, timeout, and request cancellation.
Why Codable beats manual parsing
Manual JSONSerialization returns [String: Any] and forces you to cast at every step. A single mistyped key crashes at runtime. With Codable, the compiler verifies your assumptions. When the API adds a field, your code ignores it unless you add it to the struct. When it removes one you marked non-optional, you get a clear DecodingError instead of a nil crash.
For swift codable llm api parsing, the only real gotcha is key naming and optionality. Decide early which fields are mandatory and which can be absent, and encode that in your types.
Checkpoint: full round-trip
- Define structs as above.
- POST to your endpoint (or load mock JSON).
- Decode with
parseCompletion. - Print
choices[0].message.content.
If you see the expected text, you have a working, type-safe LLM client. From here, extend the models to support tool calls, logprobs, or system fingerprints by adding optional fields. The decoder won’t break on unknown keys unless you use decodeIfPresent incorrectly.
That’s the core of integrating LLM responses into Swift without third-party dependencies. Build the structs once, and the compiler does the rest.