Most iOS apps proxy LLM calls through a server, but talking to the swift urlsession openai api directly from an iPhone is practical for local tooling, quick prototypes, and cases where you want to avoid sending user text to your own backend. You control the full request lifecycle, can meter token usage, and keep dependencies minimal. This guide builds a complete client with native Swift concurrency and no external libraries.
Prerequisites
- Xcode 15+ (Swift 5.9+ with async/await)
- A valid OpenAI API key, or any OpenAI-compatible endpoint credentials
- Swift
CodableandURLSessionbasics - iOS 15+ deployment target (required for
URLSession.data(for:)andbytes(for:))
Do not hardcode the API key in shipping code. For the examples below, we read it from an environment-injected property or a debug plist. In production, issue short-lived tokens from your own service.
Why URLSession instead of an SDK
Third-party Swift wrappers for OpenAI add abstraction you do not need for basic chat. URLSession is already async-safe, handles HTTP/2, and supports streaming via bytes(for:). You avoid version drift when the API adds a field—your Codable structs decode what you care about and ignore the rest. The swift urlsession openai api surface for chat is just one POST endpoint.
Request and response models
OpenAI’s chat completion format is stable. Define the minimal shapes you need:
struct ChatMessage: Codable {
let role: String
let content: String
}
struct ChatRequest: Codable {
let model: String
let messages: [ChatMessage]
var temperature: Double = 0.7
var stream: Bool = false
}
struct ChatResponse: Codable {
struct Choice: Codable {
let message: ChatMessage
}
let id: String
let choices: [Choice]
let usage: Usage?
struct Usage: Codable {
let prompt_tokens: Int
let completion_tokens: Int
let total_tokens: Int
}
}
That covers the non-streaming happy path. The usage field lets you track cost locally. If the API adds system_fingerprint or similar, Codable ignores it unless you add a property.
A minimal chat client
Create a struct that holds the session and auth, and exposes one async method.
struct OpenAIClient {
let baseURL: URL
let apiKey: String
let session: URLSession
init(baseURL: URL = URL(string: "https://api.openai.com/v1")!,
apiKey: String,
session: URLSession = .shared) {
self.baseURL = baseURL
self.apiKey = apiKey
self.session = session
}
func chat(_ request: ChatRequest) async throws -> ChatResponse {
var urlRequest = URLRequest(url: baseURL.appendingPathComponent("chat/completions"))
urlRequest.httpMethod = "POST"
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= http.statusCode else {
throw NSError(domain: "OpenAI", code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: String(data: data, encoding: .utf8) ?? ""])
}
return try JSONDecoder().decode(ChatResponse.self, from: data)
}
}
The baseURL defaults to OpenAI, but the same client works against any compatible gateway.
Send your first message
Wire it up in a SwiftUI view model or a plain script.
let client = OpenAIClient(apiKey: "sk-...") // replace securely
let req = ChatRequest(model: "gpt-4o-mini",
messages: [ChatMessage(role: "user", content: "Explain URLSession in one sentence.")])
do {
let res = try await client.chat(req)
if let text = res.choices.first?.message.content {
print("Assistant: \(text)")
}
if let u = res.usage {
print("Tokens — prompt: \(u.prompt_tokens), completion: \(u.completion_tokens)")
}
} catch {
print("Error: \(error.localizedDescription)")
}
Expected output (truncated):
Assistant: URLSession is Apple’s API for sending and receiving HTTP requests, handling tasks like data, download, and upload with built-in delegation and concurrency support.
Tokens — prompt: 14, completion: 27
The swift urlsession openai api call is now a typed round-trip. No Alamofire, no OpenAI package.
Handling errors and rate limits
OpenAI returns 4xx/5xx with a JSON error body. Capture it instead of dropping it:
struct APIError: Codable {
struct ErrorDetail: Codable { let message: String; let type: String }
let error: ErrorDetail
}
In chat, before decoding ChatResponse, check status and decode APIError if needed. For 429, implement exponential backoff with Task.sleep:
func chatWithRetry(_ request: ChatRequest, retries: Int = 3) async throws -> ChatResponse {
for attempt in 0...retries {
do { return try await chat(request) }
catch let e as NSError where e.code == 429 {
let delay = UInt64(pow(2.0, Double(attempt)) * 500_000_000)
try? await Task.sleep(nanoseconds: delay)
}
}
throw NSError(domain: "OpenAI", code: 429, userInfo: [NSLocalizedDescriptionKey: "Rate limited"])
}
A 401 means the token is missing or malformed. A 400 usually means a bad model name or malformed messages array—log the decoded APIError to see the detail.
Streaming tokens with Server-Sent Events
Non-streaming blocks until the full completion finishes. For chat UX, stream. Set stream: true and read the response body as a byte stream.
func streamChat(_ request: ChatRequest) async throws -> AsyncThrowingStream<String, Error> {
var mutable = request
mutable.stream = true
var urlRequest = URLRequest(url: baseURL.appendingPathComponent("chat/completions"))
urlRequest.httpMethod = "POST"
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try JSONEncoder().encode(mutable)
let (bytes, response) = try await session.bytes(for: urlRequest)
guard let http = response as? HTTPURLResponse, 200...299 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
return AsyncThrowingStream { continuation in
Task {
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let payload = String(line.dropFirst(6))
if payload == "[DONE]" { continuation.finish(); break }
if let data = payload.data(using: .utf8),
let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
let token = chunk.choices.first?.delta.content {
continuation.yield(token)
}
}
}
continuation.finish()
}
}
}
struct StreamChunk: Codable {
struct Choice: Codable {
struct Delta: Codable { let content: String? }
let delta: Delta
}
let choices: [Choice]
}
Consume it:
let stream = try await client.streamChat(req)
for try await token in stream {
print(token, terminator: "")
}
print()
Expected incremental console output:
URLSession
is
Apple
’s
API
…
The swift urlsession openai api streaming path uses the same auth and URL; only the decoding loop changes. Note that SSE lines are prefixed with data: and terminated by a blank line. bytes.lines splits on newline, so we drop the prefix and ignore heartbeats.
Cancelling in-flight requests
URLSession respects Task cancellation if you use the async APIs. Wrap the call in a Task and call .cancel() from a SwiftUI .onDisappear or a button. The bytes.lines loop throws CancellationError when the parent task is cancelled, terminating the stream cleanly.
let task = Task {
let stream = try await client.streamChat(req)
for try await token in stream { print(token, terminator: "") }
}
// later
task.cancel()
Swap endpoints without rewriting
The request and response contracts are identical across OpenAI-compatible servers. If you target an OpenAI-compatible gateway such as n4n.ai—which fronts 240+ models and fails over automatically when a provider is rate-limited—the same Swift structs and URLSession call work unchanged; only the baseURL and bearer token differ. This keeps your iOS client dumb and lets backend policy control model routing.
Testing with a stubbed URLProtocol
To unit test without network, register a custom URLProtocol that returns a canned ChatResponse JSON. Your OpenAIClient takes a URLSession in init, so inject session with the stub configuration. This validates decoding and error mapping without burning tokens.
class StubProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let json = "{\"id\":\"x\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}".data(using: .utf8)!
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: json)
client?.urlProtocolDidFinishLoading(self)
}
}
Security and next steps
- Store the key in the Keychain, or better, issue short-lived client tokens from your own auth service.
- Use
URLSessionConfiguration.ephemeralif you want to avoid disk caching of prompts. - For background uploads of large batched prompts, use
URLSessionDownloadTaskwith a delegate; the JSON shape stays the same. - Honor
usageto show users token counts before they hit send.
You now have a typed, streaming, retry-aware client built entirely on URLSession. Extend it with function calling by adding tools to ChatRequest, or with vision by embedding base64 images in ChatMessage content arrays—both are plain Codable changes.
That is the full surface area most iOS apps need to integrate LLMs without taking a dependency on a moving SDK.