Choose Between On-Device Foundation Models And Private Cloud Compute

The interesting WWDC26 Foundation Models question is no longer “can I call a model from Swift?” We can already do that.

The better question is: when should this request stay on device, and when should it use Apple’s new PrivateCloudComputeLanguageModel?

That choice matters because both models fit the same LanguageModelSession shape, but they have different product constraints. The on-device system model can work offline and does not have request limits. Private Cloud Compute gives you a larger context window and reasoning levels, but it needs network access, has per-user daily limits, and requires your app to be eligible for PCC access.

The examples below follow the WWDC26 API direction Apple showed for Xcode 27-era SDKs. Because this is beta-season code, verify exact names against the SDK you are using before pasting any snippet into production.

One eligibility note: Apple’s current PCC access page says developer access depends on App Store Small Business Program participation, having fewer than 2 million first-time downloads across your apps, and a PCC entitlement assigned to your app. Treat that page, not a blog post, as the source of truth before you build a feature that depends on PCC.

The one-line switch is real

If you already have a Foundation Models feature, the API difference can be small.

The on-device model often starts like this:

import FoundationModels

let session = LanguageModelSession()

let response = try await session.respond(
    to: "Summarize this article: \(article)"
)

print(response.content)

The Private Cloud Compute version changes the model used by the session:

import FoundationModels

let session = LanguageModelSession(
    model: PrivateCloudComputeLanguageModel()
)

let response = try await session.respond(
    to: "Summarize this article: \(article)"
)

print(response.content)

That shared shape is the big deal. Structured output, prompt building, streaming, and tool calling are still session work. Your app should not need two completely different AI stacks just because one request uses the local system model and another uses PCC.

But “easy to switch” is not the same as “always switch.”

The practical model choice

Start with the on-device model when the task is short, latency-sensitive, offline-friendly, or something the user expects to stay entirely local.

Use Private Cloud Compute when the request needs one of the things PCC is specifically better at:

  • A larger context window.
  • A reasoning level.
  • Long documents or multiple documents.
  • Larger generated outputs.
  • Complex tool-heavy work.
  • Features on watchOS that need Foundation Models through PCC.

Apple’s WWDC26 sessions describe the important split this way: the on-device model is private, offline-capable, and has no request limits; PCC is private, server-backed, requires an internet connection, has daily per-user limits, and supports a larger context window with reasoning.

Do not hard-code that decision from vibes. Build the decision into app state.

Read capabilities instead of guessing

Apple showed contextSize on both model types. That matters because context limits are not trivia. They decide whether a “summarize this document” button succeeds or fails.

import FoundationModels

let localModel = SystemLanguageModel()
let cloudModel = PrivateCloudComputeLanguageModel()

print(localModel.contextSize)
print(cloudModel.contextSize)

Apple’s WWDC26 PCC session showed PCC with a larger context window than the on-device model, and it showed the on-device context size changing across OS and device generations. Treat contextSize as a runtime capability, not a constant you memorized during the keynote.

Make those facts explicit:

struct ModelCapabilities: Equatable {
    var localContextSize: Int
    var cloudContextSize: Int
    var isCloudAvailable: Bool
    var isNetworkReachable: Bool
    var isCloudLimitReached: Bool
    var isCloudNearLimit: Bool
}

The live Foundation Models APIs can fill this snapshot. Your routing logic can stay ordinary Swift, which makes it much easier to test.

Be careful with token estimates. The / 4 helper below is only a teaching shortcut. A real request also spends context on instructions, structured-output schemas, tool definitions, transcript history, and reasoning. Use the SDK’s token-counting API if your current seed exposes one, and keep some headroom instead of filling the entire context window.

Model the route

Let’s build a document summarizer that starts local and escalates to PCC only when the task asks for it.

struct SummaryDraft: Equatable {
    var title: String = ""
    var body: String = ""

    var approximateTokenBudget: Int {
        // This is intentionally rough. If your SDK exposes token counting,
        // prefer that for preflight checks.
        max(1, (title.count + body.count) / 4)
    }
}

enum ReasoningPreference: Equatable {
    case none
    case light
    case moderate
    case deep

    var label: String {
        switch self {
        case .none:
            "quick"
        case .light:
            "light"
        case .moderate:
            "careful"
        case .deep:
            "deep review"
        }
    }
}

enum ModelRoute: Equatable {
    case onDevice
    case privateCloud(reasoning: ReasoningPreference)
}

enum RouteDecision: Equatable {
    case ready(ModelRoute, notice: RouteNotice? = nil)
    case blocked(SummarizerBlocker)
}

enum RouteNotice: Equatable {
    case privateCloudNearLimit
    case privateCloudUnavailableUsingLocal
    case privateCloudNeedsNetworkUsingLocal
    case privateCloudLimitReachedUsingLocal
}

struct ModelRouter {
    var capabilities: ModelCapabilities

    func decision(
        for draft: SummaryDraft,
        preferredReasoning: ReasoningPreference
    ) -> RouteDecision {
        let tokenBudget = draft.approximateTokenBudget
        let localHeadroom = 512
        let cloudHeadroom = 1_024

        let fitsLocal = tokenBudget + localHeadroom <= capabilities.localContextSize
        let fitsCloud = tokenBudget + cloudHeadroom <= capabilities.cloudContextSize
        let prefersCloud = preferredReasoning == .moderate ||
            preferredReasoning == .deep ||
            tokenBudget > capabilities.localContextSize / 2

        if !prefersCloud, fitsLocal {
            return .ready(.onDevice, notice: localFallbackNotice())
        }

        if fitsCloud {
            if !capabilities.isNetworkReachable {
                return fitsLocal
                    ? .ready(.onDevice, notice: .privateCloudNeedsNetworkUsingLocal)
                    : .blocked(.privateCloudNeedsNetwork)
            }

            if !capabilities.isCloudAvailable {
                return fitsLocal
                    ? .ready(.onDevice, notice: .privateCloudUnavailableUsingLocal)
                    : .blocked(.privateCloudUnavailable)
            }

            if capabilities.isCloudLimitReached {
                return fitsLocal
                    ? .ready(.onDevice, notice: .privateCloudLimitReachedUsingLocal)
                    : .blocked(.privateCloudLimitReached)
            }

            return .ready(
                .privateCloud(reasoning: preferredReasoning),
                notice: capabilities.isCloudNearLimit ? .privateCloudNearLimit : nil
            )
        }

        if fitsLocal {
            return .ready(.onDevice, notice: localFallbackNotice())
        }

        return .blocked(.promptTooLargeForAvailableModels)
    }

    private func localFallbackNotice() -> RouteNotice? {
        if capabilities.isCloudLimitReached {
            return .privateCloudLimitReachedUsingLocal
        }

        if capabilities.isCloudNearLimit {
            return .privateCloudNearLimit
        }

        return nil
    }
}

That router is intentionally conservative. If the request easily fits locally, keep it local. If it looks too large or benefits from deeper synthesis, try PCC only when the app knows PCC is available. If the prompt fits neither model, block with a useful explanation instead of pretending a fallback exists.

Reasoning is useful, but it is not free

PCC supports reasoning levels:

let response = try await session.respond(
    to: prompt,
    contextOptions: ContextOptions(reasoningLevel: .moderate)
)

Reasoning lets the model spend more work before producing the final answer. That can improve harder tasks, but it also costs time and context. Apple notes that reasoning generates additional transcript content and counts against the context window.

For a document summarizer, I would expose the choice as product language instead of model language:

enum SummaryDepth: String, CaseIterable, Identifiable {
    case quick
    case careful
    case deepReview

    var id: String { rawValue }

    var reasoning: ReasoningPreference {
        switch self {
        case .quick:
            .none
        case .careful:
            .moderate
        case .deepReview:
            .deep
        }
    }
}

A quick one-page summary probably does not need PCC deep reasoning. A messy product requirements document with contradictions might.

Preserve the user’s draft

The worst fallback UX is an alert that says the model failed and leaves the user wondering whether their input is gone.

Keep the draft separate from generation state:

import FoundationModels
import Observation

@Generable
struct DocumentSummary {
    var title: String
    var oneLineSummary: String
    var keyPoints: [String]
    var openQuestions: [String]
}

enum SummarizerState {
    case editing
    case ready(ModelRoute, notice: RouteNotice?)
    case generating(ModelRoute)
    case blocked(SummarizerBlocker)
    case failed(message: String)
    case finished(ModelRoute)
}

enum SummarizerBlocker {
    case privateCloudUnavailable
    case privateCloudNeedsNetwork
    case privateCloudNearLimit
    case privateCloudLimitReached
    case promptTooLargeForAvailableModels
}

@MainActor
@Observable
final class DocumentSummarizerModel {
    var draft = SummaryDraft()
    var selectedDepth: SummaryDepth = .careful
    var state: SummarizerState = .editing
    var summary: DocumentSummary?

    private var capabilities = ModelCapabilities(
        localContextSize: 0,
        cloudContextSize: 0,
        isCloudAvailable: false,
        isNetworkReachable: true,
        isCloudLimitReached: false,
        isCloudNearLimit: false
    )

    func refreshCapabilities() {
        // Fill this from SystemLanguageModel(),
        // PrivateCloudComputeLanguageModel(), network reachability,
        // and quotaUsage in your current SDK.
        capabilities = loadCurrentCapabilities()

        switch recommendedDecision() {
        case .ready(let route, let notice):
            state = .ready(route, notice: notice)

        case .blocked(let blocker):
            state = .blocked(blocker)
        }
    }

    func summarize() {
        let currentDraft = draft
        let decision = recommendedDecision()

        guard case .ready(let route, _) = decision else {
            if case .blocked(let blocker) = decision {
                state = .blocked(blocker)
            }
            return
        }

        state = .generating(route)
        summary = nil

        Task {
            do {
                let result = try await generateSummary(
                    for: currentDraft,
                    route: route
                )

                await MainActor.run {
                    summary = result
                    state = .finished(route)
                }
            } catch {
                await MainActor.run {
                    // Keep draft untouched. The user can edit, retry,
                    // or switch routes without losing their input.
                    state = .failed(message: error.localizedDescription)
                }
            }
        }
    }

    private func recommendedDecision() -> RouteDecision {
        ModelRouter(capabilities: capabilities)
            .decision(
                for: draft,
                preferredReasoning: selectedDepth.reasoning
            )
    }

    private func loadCurrentCapabilities() -> ModelCapabilities {
        let localModel = SystemLanguageModel()
        let cloudModel = PrivateCloudComputeLanguageModel()
        let isCloudNearLimit: Bool

        if case .belowLimit(let info) = cloudModel.quotaUsage.status {
            isCloudNearLimit = info.isApproachingLimit
        } else {
            isCloudNearLimit = false
        }

        return ModelCapabilities(
            localContextSize: localModel.contextSize,
            cloudContextSize: cloudModel.contextSize,
            isCloudAvailable: cloudModel.isAvailable,
            isNetworkReachable: true,
            isCloudLimitReached: cloudModel.quotaUsage.isLimitReached,
            isCloudNearLimit: isCloudNearLimit
        )
    }
}

That loadCurrentCapabilities() method is the part most likely to move during the beta cycle. The important shape is stable: collect live model facts once, turn them into app state, then keep routing logic testable.

If your SDK exposes quotaUsage.limitIncreaseSuggestion, keep it with the blocked state. Apple’s WWDC26 examples show a suggestion object that can present options to the user. That belongs in a persistent UI surface, not a disappearing alert.

Generate with the selected route

Avoid type-erasure cleverness at first. A plain switch is easier to read and easier to test.

import FoundationModels

func generateSummary(
    for draft: SummaryDraft,
    route: ModelRoute
) async throws -> DocumentSummary {
    let prompt = """
    Summarize the following document for an iOS developer.

    Keep the response practical. Call out decisions, risks, and open questions.
    Do not invent APIs, dates, or requirements.

    Title:
    \(draft.title)

    Document:
    \(draft.body)
    """

    switch route {
    case .onDevice:
        let session = LanguageModelSession(
            instructions: """
            You summarize documents clearly and conservatively.
            Prefer specific claims from the source document.
            """
        )

        let response = try await session.respond(
            to: prompt,
            generating: DocumentSummary.self
        )

        return response.content

    case .privateCloud(let reasoning):
        let session = LanguageModelSession(
            model: PrivateCloudComputeLanguageModel(),
            instructions: """
            You summarize long technical documents for app developers.
            Preserve nuance. Separate facts from open questions.
            """
        )

        return try await respondWithReasoning(
            session: session,
            prompt: prompt,
            reasoning: reasoning
        )
    }
}

private func respondWithReasoning(
    session: LanguageModelSession,
    prompt: String,
    reasoning: ReasoningPreference
) async throws -> DocumentSummary {
    switch reasoning {
    case .none:
        let response = try await session.respond(
            to: prompt,
            generating: DocumentSummary.self
        )
        return response.content

    case .light:
        let response = try await session.respond(
            to: prompt,
            generating: DocumentSummary.self,
            contextOptions: ContextOptions(reasoningLevel: .light)
        )
        return response.content

    case .moderate:
        let response = try await session.respond(
            to: prompt,
            generating: DocumentSummary.self,
            contextOptions: ContextOptions(reasoningLevel: .moderate)
        )
        return response.content

    case .deep:
        let response = try await session.respond(
            to: prompt,
            generating: DocumentSummary.self,
            contextOptions: ContextOptions(reasoningLevel: .deep)
        )
        return response.content
    }
}

This is slightly repetitive, but it keeps the beta API surface obvious. Once the SDK settles, you can clean it up.

Build persistent fallback UI

Apple specifically recommends persistent usage-limit UI instead of a dismissible alert. That makes sense. If the user is near a daily limit or has reached it, they need the app to keep that state visible while they decide what to do.

import SwiftUI

struct DocumentSummarizerView: View {
    @State private var model = DocumentSummarizerModel()

    var body: some View {
        Form {
            Section("Document") {
                TextField("Title", text: $model.draft.title)

                TextEditor(text: $model.draft.body)
                    .frame(minHeight: 220)
            }

            Section("Model") {
                Picker("Depth", selection: $model.selectedDepth) {
                    Text("Quick").tag(SummaryDepth.quick)
                    Text("Careful").tag(SummaryDepth.careful)
                    Text("Deep Review").tag(SummaryDepth.deepReview)
                }

                Button(buttonTitle) {
                    model.summarize()
                }
                .disabled(!canSubmit)
            } footer: {
                ModelStatusFooter(state: model.state)
            }

            if let summary = model.summary {
                Section("Summary") {
                    Text(summary.oneLineSummary)

                    ForEach(summary.keyPoints, id: \.self) { point in
                        Text(point)
                    }

                    if !summary.openQuestions.isEmpty {
                        Text("Open Questions")
                            .font(.headline)

                        ForEach(summary.openQuestions, id: \.self) { question in
                            Text(question)
                        }
                    }
                }
            }
        }
        .navigationTitle("Summarize")
        .onAppear {
            model.refreshCapabilities()
        }
        .onChange(of: model.selectedDepth) {
            model.refreshCapabilities()
        }
    }

    private var buttonTitle: String {
        if case .generating = model.state {
            return "Summarizing..."
        }

        return "Summarize"
    }

    private var canSubmit: Bool {
        !model.draft.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
        !isGenerating &&
        !isHardBlocked
    }

    private var isGenerating: Bool {
        if case .generating = model.state { return true }
        return false
    }

    private var isHardBlocked: Bool {
        if case .blocked = model.state {
            return true
        }

        return false
    }
}

struct ModelStatusFooter: View {
    let state: SummarizerState

    var body: some View {
        switch state {
        case .editing:
            Text("Choose a document to summarize.")

        case .ready(.onDevice, notice: let notice):
            VStack(alignment: .leading, spacing: 6) {
                Text("This request will use the on-device model.")

                if let notice {
                    RouteNoticeText(notice: notice)
                }
            }

        case .ready(.privateCloud(let reasoning), notice: let notice):
            VStack(alignment: .leading, spacing: 6) {
                Text("This request will use Private Cloud Compute for a \(reasoning.label) summary.")

                if let notice {
                    RouteNoticeText(notice: notice)
                }
            }

        case .generating(.onDevice):
            Text("Generating on device.")

        case .generating(.privateCloud):
            Text("Generating with Private Cloud Compute.")

        case .blocked(.privateCloudNearLimit):
            Text("You are nearing your Private Cloud Compute daily limit. Your draft is still here.")

        case .blocked(.privateCloudLimitReached):
            VStack(alignment: .leading, spacing: 8) {
                Text("Private Cloud Compute is at its daily limit. Your draft is still here.")
                Text("Shorten the document, split it into sections, or use Apple's limit-management option when the current SDK exposes one.")
            }

        case .blocked(.privateCloudUnavailable):
            Text("Private Cloud Compute is not available on this device, account, app, or region.")

        case .blocked(.privateCloudNeedsNetwork):
            Text("Private Cloud Compute needs an internet connection. You can keep editing or try the on-device model.")

        case .blocked(.promptTooLargeForAvailableModels):
            Text("This document is too large for the currently available model. Shorten it or split it into sections.")

        case .failed(let message):
            Text(message)

        case .finished(.onDevice):
            Text("Generated on device.")

        case .finished(.privateCloud):
            Text("Generated with Private Cloud Compute.")
        }
    }
}

struct RouteNoticeText: View {
    let notice: RouteNotice

    var body: some View {
        switch notice {
        case .privateCloudNearLimit:
            Text("Private Cloud Compute is nearing today's usage limit.")

        case .privateCloudUnavailableUsingLocal:
            Text("Private Cloud Compute is unavailable, so this request will stay on device.")

        case .privateCloudNeedsNetworkUsingLocal:
            Text("Private Cloud Compute needs a network connection, so this request will stay on device.")

        case .privateCloudLimitReachedUsingLocal:
            Text("Private Cloud Compute is at today's limit, so this request will stay on device.")
        }
    }
}

There are two deliberate UX choices here.

The draft stays visible. A network failure, quota state, or availability issue should not erase the user’s work.

The status lives near the button. If the user cannot submit, the reason should be in the same place as the disabled action.

Handle availability as a product state

For the local system model, check availability. For PCC, check availability, network, and quota.

The exact unavailable reasons may vary by SDK, but the product states are stable:

enum ModelAvailabilitySummary {
    case available
    case appleIntelligenceUnavailable
    case privateCloudUnavailable
    case networkRequired
    case quotaNearLimit
    case quotaReached
}

Common cases to design for:

  • The device does not support Apple Intelligence.
  • Apple Intelligence is not enabled.
  • The model is not ready yet.
  • Private Cloud Compute is not available for this app, account, region, or device.
  • The device is offline.
  • The user is approaching their daily PCC limit.
  • The user has reached their daily PCC limit.
  • The prompt is too large for the selected model.
  • The request fails after starting.

That last one is why the route should be retryable. Let the user switch from PCC to on-device without rebuilding the whole screen.

Test the router without real models

Most of your routing logic does not need a real model. Turn the live model facts into a small snapshot and test that.

import Testing

@Test
func shortDraftUsesOnDevice() {
    let router = ModelRouter(
        capabilities: ModelCapabilities(
            localContextSize: 8_192,
            cloudContextSize: 32_768,
            isCloudAvailable: true,
            isNetworkReachable: true,
            isCloudLimitReached: false,
            isCloudNearLimit: false
        )
    )

    let draft = SummaryDraft(
        title: "Short note",
        body: "Ship the onboarding copy update."
    )

    #expect(
        router.decision(for: draft, preferredReasoning: .light)
            == .ready(.onDevice)
    )
}

@Test
func longDraftUsesPrivateCloudWhenAvailable() {
    let router = ModelRouter(
        capabilities: ModelCapabilities(
            localContextSize: 8_192,
            cloudContextSize: 32_768,
            isCloudAvailable: true,
            isNetworkReachable: true,
            isCloudLimitReached: false,
            isCloudNearLimit: false
        )
    )

    let draft = SummaryDraft(
        title: "Long document",
        body: String(repeating: "Requirements and notes. ", count: 3_000)
    )

    #expect(
        router.decision(for: draft, preferredReasoning: .moderate)
            == .ready(.privateCloud(reasoning: .moderate))
    )
}

@Test
func cloudLimitStillAllowsShortLocalDraft() {
    let router = ModelRouter(
        capabilities: ModelCapabilities(
            localContextSize: 8_192,
            cloudContextSize: 32_768,
            isCloudAvailable: true,
            isNetworkReachable: true,
            isCloudLimitReached: true,
            isCloudNearLimit: false
        )
    )

    let draft = SummaryDraft(
        title: "Short note",
        body: "Ship the onboarding copy update."
    )

    #expect(
        router.decision(for: draft, preferredReasoning: .light)
            == .ready(.onDevice, notice: .privateCloudLimitReachedUsingLocal)
    )
}

@Test
func cloudLimitBlocksWhenDraftDoesNotFitLocal() {
    let router = ModelRouter(
        capabilities: ModelCapabilities(
            localContextSize: 8_192,
            cloudContextSize: 32_768,
            isCloudAvailable: true,
            isNetworkReachable: true,
            isCloudLimitReached: true,
            isCloudNearLimit: false
        )
    )

    let draft = SummaryDraft(
        title: "Long document",
        body: String(repeating: "Requirements and notes. ", count: 3_000)
    )

    #expect(
        router.decision(for: draft, preferredReasoning: .moderate)
            == .blocked(.privateCloudLimitReached)
    )
}

You should also test the live availability UI. Apple showed an Xcode scheme option for simulating Foundation Models availability states, including quota limit reached and nearing usage limit. Use that. It is much better than waiting for a real account to hit a real limit.

For quality testing, the Evaluations framework is the right direction. Create a fixed set of documents, expected summary traits, and failure cases. Compare on-device, PCC light, PCC moderate, and PCC deep. The local model may be good enough more often than you expect, and PCC deep reasoning may not be worth the latency for every feature.

Be clear about privacy boundaries

Apple positions both options as privacy-preserving, but they are not identical from a user’s point of view.

On-device means the model runs locally and can work offline.

Private Cloud Compute sends the request to Apple’s PCC infrastructure. Apple says PCC request data is not stored and is used only for the request, with independent verification of the privacy claims. Apple also emphasizes that PCC is integrated with the OS and iCloud, so your app does not handle API keys or cloud model authentication.

That is a strong privacy story, but it does not mean you should silently route every sensitive document to PCC. If the feature handles private journals, health notes, legal documents, enterprise data, or unreleased source material, make the routing understandable. Give users a local-only option when that matters.

Also keep PCC separate from third-party model integrations. The WWDC26 Foundation Models model abstraction can support other providers, but those providers have different authentication, billing, and privacy boundaries.

Beta caveats

This is June 23, 2026 code, based on WWDC26 sessions and beta SDK examples.

Before shipping, verify:

  • The exact SystemLanguageModel initializer or default instance your SDK expects.
  • The concrete availability API shape for both model types.
  • The concrete type returned by quotaUsage.limitIncreaseSuggestion.
  • The current ContextOptions(reasoningLevel:) spelling.
  • Whether your app has the required Private Cloud Compute entitlement.
  • Whether your developer account and app distribution meet Apple’s PCC access requirements.
  • Region, language, Apple Intelligence, and device support for the users you serve.

Keep the router small, heavily tested, and easy to change. This is exactly the kind of beta-era surface that can move slightly while the product idea remains solid.

Official sources