Streaming messages from ChatGPT using Swift AsyncSequence
I was recently playing with the ChatGPT API (like everyone else in the world) and hit the point of needing to consume their streaming API in Swift. The API uses server-sent events to stream partial responses so you can update the UI in real-time as the response streams in. This gave me a chance to use a new protocol introduced in Swift 5.5 called AsyncSequence
for the first time with URLSession
. John Sundell has a great article that explains it more in depth, but the gist you can consume a sequence asynchronously as easy as you would iterating through an array:
for try await value in asyncSequence {
// do something with `value`
}
To use that for HTTP requests to process data as it’s received, you can use a new URLSession
method (as of iOS 15.0) that returns a tuple of (URLSession.AsyncBytes, URLResponse)
. The AsyncBytes
type has methods to get an AsyncSequence
of individual characters or entire lines. Using lines
saves us a lot of work where we can let URLSession
buffer until a newline is received and give us the whole message at once. I was surprised how easy it was to get this working in Swift for ChatGPT.
let (bytes, response) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
print(line)
}
Each line
we get there will be partial response, and all we need to do is parse the message. That is less interesting, but here’s a demo of how it looks and a full code sample that you can run from the command-line to query ChatGPT and stream the messages in a simple Swift script is below.
import Foundation
/// Execute a streaming request to ChatGPT and prints the response as it comes in
/// Set OPENAI_API_KEY environment variable to a valid API key
/// This is simplified version to demonstrate using async sequences from an API in Swift
/// and is not production ready as it has almost zero error handling
///
/// Usage:
/// swift chat.swift <query>
///
guard let apiKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"] else {
print("*** Error: No OpenAI API key provided! Set an $OPENAI_API_KEY environment variable and try again ")
exit(1)
}
guard CommandLine.arguments.count == 2 else {
print("Usage: swift chat.swift \"<query>\"")
exit(1)
}
let content = CommandLine.arguments[1]
let request = try makeRequest(content: content, apiKey: apiKey)
let (stream, _) = try await URLSession.shared.bytes(for: request)
for try await line in stream.lines {
guard let message = parse(line) else { continue }
print(message, terminator: "")
fflush(stdout)
}
/// Make a URL request for the ChatGPT endpoint
/// https://platform.openai.com/docs/api-reference/chat/create
func makeRequest(content: String, apiKey: String) throws -> URLRequest {
let query = Query(messages: [.init(role: "user", content: content)])
let url = URL(string: "https://api.openai.com/v1/chat/completions")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(query)
request.allHTTPHeaderFields = [
"Content-Type": "application/json",
"Authorization": "Bearer \(apiKey)"
]
return request
}
/// Parse a line from the stream and extract the message
func parse(_ line: String) -> String? {
let components = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true)
guard components.count == 2, components[0] == "data" else { return nil }
let message = components[1].trimmingCharacters(in: .whitespacesAndNewlines)
if message == "[DONE]" {
return "\n"
} else {
let chunk = try? JSONDecoder().decode(Chunk.self, from: message.data(using: .utf8)!)
return chunk?.choices.first?.delta.content
}
}
/// Encodes the query
struct Query: Encodable {
struct Message: Encodable {
let role: String
let content: String
}
let model = "gpt-3.5-turbo"
let messages: [Message]
let stream = true
}
/// Decodes a chunk of the streaming response
struct Chunk: Decodable {
struct Choice: Decodable {
struct Delta: Decodable {
let role: String?
let content: String?
}
let delta: Delta
}
let choices: [Choice]
}
Note: you need to set an environment variable for OPENAI_API_KEY
to use it, then you can run swift chat.swift <query>