n4nAI

Parsing LLM API responses in Swift with Codable

Hands-on tutorial: swift codable llm api parsing for iOS. Define Codable structs for OpenAI-style chat responses and decode JSON reliably.

n4n Team3 min read653 words

Audio narration

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

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 URLSession and 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. Foundation is 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

  1. Define structs as above.
  2. POST to your endpoint (or load mock JSON).
  3. Decode with parseCompletion.
  4. 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.

Tagsswiftcodablejson-parsingllm-api

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 →