ChatGPTAuthKit (Swift)
Sign in with a ChatGPT subscription and call the Codex Responses API from your iOS app.
Zero third-party dependencies. Auth, Keychain storage, auto-refresh, and a small built-in Responses / Models / Usage client. Want to use another OpenAI SDK (MacPaw, etc.)? It's a few lines of config — see Plugging in other OpenAI SDKs.
Install (Swift Package Manager)
.package(url: "https://github.com/mobai-app/chatgpt-auth-kit", from: "0.1.0"),Or in Xcode: File → Add Package Dependencies → paste the URL.
Usage
import ChatGPTAuthKit
@MainActor
final class AuthViewModel: ObservableObject {
@Published var pendingURL: URL?
@Published var showSafari = false
@Published var credentials: Credentials?
private let store = CredentialsStore()
private let flow = OAuthFlow()
init() { credentials = store.load() }
func login() async {
do {
let creds = try await flow.run(
present: { url in await MainActor.run { self.pendingURL = url; self.showSafari = true } },
onComplete: { await MainActor.run { self.showSafari = false } }
)
try? store.save(creds)
credentials = creds
} catch {
print("login failed: \(error)")
}
}
func signOut() {
store.clear()
credentials = nil
}
}struct ContentView: View {
@StateObject var auth = AuthViewModel()
var body: some View {
VStack {
if auth.credentials == nil {
SignInWithOpenAIButton { Task { await auth.login() } }
} else {
Text("Signed in: \(auth.credentials!.accountID)")
Button("Sign out", action: auth.signOut)
}
}
.sheet(isPresented: $auth.showSafari) {
if let url = auth.pendingURL { SafariView(url: url) }
}
}
}Auto-refresh
For long-lived sessions, wrap credentials in a RefreshingCredentialsProvider once at app start. It hands out fresh credentials on demand, persists each refresh to the Keychain, and coalesces concurrent refreshes:
let provider = RefreshingCredentialsProvider(credentials: creds, store: store)
let chat = ResponsesClient(provider: provider)
let models = ModelsClient(provider: provider)
let usage = UsageClient(provider: provider)Every .fetch() / .stream(...) call goes through the provider, so a near-expiry token is refreshed before the request goes out — you never have to hand-manage OAuthClient.refresh(...) yourself. For one-off use, each client also has an init(credentials:) that takes a single Credentials value.
Streaming a completion
let client = ResponsesClient(provider: provider)
for try await event in client.stream(messages: [
.init(role: .system, content: "You are helpful."),
.init(role: .user, content: "What's a fun weekend in Berlin?"),
]) {
if case .delta(let chunk) = event { print(chunk, terminator: "") }
}Discover available models
let models = try await ModelsClient(provider: provider).fetch()
for m in models { print(m.slug, m.displayName ?? "", m.isDefault) }Read plan & quota
let usage = try await UsageClient(provider: provider).fetch()
print(usage.planType ?? "?", usage.primary?.usedPercent ?? 0)Info.plist
The loopback listener needs:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Used to receive the OAuth callback on 127.0.0.1:1455.</string>iOS 14+ will prompt the user once when the listener first binds.
Plugging in other OpenAI SDKs
The built-in ResponsesClient is intentionally minimal (text streaming + usage). If you'd rather use a third-party SDK — MacPaw/OpenAI, your own URLSession layer, anything HTTP — point it at the Codex backend with the right headers and body shape. ChatGPTAuthKit gives you the credentials; the integration is config in your app.
Where it lives
- Host:
chatgpt.com - Base path:
/backend-api/codex - Bearer token:
credentials.accessToken
Headers Codex requires
Authorization: Bearer <accessToken>ChatGPT-Account-ID: <accountID>OpenAI-Beta: responses=experimentaloriginator: codex_cli_rs(or your app's identifier)
Body shape Codex enforces — a 400 if any of these is wrong
instructions: Stringreasoning: { effort, summary }include: ["reasoning.encrypted_content"]store: falseinputas a structured array — system messages collapse intoinstructions, user/assistant become{type: "message", role, content: [{type: "input_text"|"output_text", text}]}
Example: MacPaw/OpenAI
Add MacPaw to your app's package graph:
.package(url: "https://github.com/MacPaw/OpenAI", from: "0.4.9"),Drop these helpers anywhere in your app:
import OpenAI
import ChatGPTAuthKit
extension Credentials {
func makeOpenAI(originator: String = "codex_cli_rs",
timeoutInterval: TimeInterval = 60) -> OpenAI {
OpenAI(configuration: .init(
token: accessToken,
organizationIdentifier: nil,
host: "chatgpt.com",
port: 443,
scheme: "https",
basePath: "/backend-api/codex",
timeoutInterval: timeoutInterval,
customHeaders: [
"OpenAI-Beta": "responses=experimental",
"originator": originator,
"ChatGPT-Account-ID": accountID,
]
))
}
}
extension CredentialsProvider {
/// Pull fresh credentials and build a MacPaw client. Call this each time —
/// MacPaw caches the bearer at construction, so a new client per request
/// keeps auto-refresh effective.
func makeOpenAI(originator: String = "codex_cli_rs") async throws -> OpenAI {
let creds = try await currentCredentials()
return creds.makeOpenAI(originator: originator)
}
}
extension OpenAI {
/// Builds a Codex-shaped query: structured `inputItemList`, explicit
/// `instructions`, `reasoning`, and `include: [reasoning.encrypted_content]`.
/// Sending a plain `.textInput(...)` or omitting these fields is rejected with a 400.
static func codexQuery(
prompt: String,
model: String = "gpt-5.5",
instructions: String = "Follow the user's instructions.",
reasoningEffort: Components.Schemas.ReasoningEffort = .medium,
stream: Bool = true
) -> CreateModelResponseQuery {
CreateModelResponseQuery(
input: .inputItemList([.inputMessage(EasyInputMessage(role: .user, content: .textInput(prompt)))]),
model: model,
include: [.reasoning_encryptedContent],
instructions: instructions,
reasoning: .init(effort: reasoningEffort, summary: .auto),
store: false,
stream: stream
)
}
}Then use it like any MacPaw client:
let client = try await provider.makeOpenAI()
for try await event in client.responses.createResponseStreaming(
query: OpenAI.codexQuery(prompt: "Say hi in one sentence.")
) {
if case .outputText(.delta(let d)) = event { print(d.delta, terminator: "") }
}Tools, function calling, conversation state, and everything else are MacPaw's own surface — see their docs. The Codex constraints above (headers, body shape, reasoning replay) apply to those flows the same way.
License
MIT.