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=experimental
  • originator: codex_cli_rs (or your app's identifier)

Body shape Codex enforces — a 400 if any of these is wrong

  • instructions: String
  • reasoning: { effort, summary }
  • include: ["reasoning.encrypted_content"]
  • store: false
  • input as a structured array — system messages collapse into instructions, 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.