An AI feature becomes expensive to change the moment the view model knows which vendor produced the answer. The first prototype may only create a LanguageModelSession, but production soon adds privacy modes, unavailable devices, regional constraints, token budgets, cloud credentials, and a second model that is better at one particular job.
The iOS 27-era Foundation Models APIs point toward a better boundary. Apple now describes LanguageModel as a common abstraction for its on-device model, Private Cloud Compute, local Core AI and MLX models, and provider packages. Product code can ask for a capability instead of naming a vendor.
This article builds that boundary. The goal is not a universal wrapper around every possible LLM API. It is a small application-owned layer that preserves structured output and tools, makes routing policy visible, and lets the model change without rewriting the feature.
Beta note: The provider abstraction, Private Cloud Compute integration, session usage APIs, and several transcript features discussed here come from Apple’s WWDC26 material and iOS 27-era beta documentation. Names and behavior may change before the final SDK. Compile against the exact Xcode beta you support and re-verify every signature before shipping.
1. Start With a Product Capability, Not a Model
Suppose a journaling app turns an entry into a short title, mood, and set of themes. That feature has requirements independent of any provider:
- output must match a schema;
- private entries should stay on-device when possible;
- the screen must remain usable when AI is unavailable;
- a cloud request may have a latency and cost ceiling;
- tests need stable results.
Those requirements belong in the application layer. The model is an implementation detail selected after policy is evaluated. That direction of dependency is the same one described in MVVM with Clean Architecture in iOS: feature code depends on a use-case boundary, while infrastructure depends inward on the feature’s contract.
Model the result as a domain value:
import Foundation
struct JournalInsight: Codable, Equatable, Sendable {
let title: String
let mood: String
let themes: [String]
}
struct InsightRequest: Sendable {
let entry: String
let containsSensitiveData: Bool
let locale: Locale
}
protocol JournalInsightGenerating: Sendable {
func generateInsight(for request: InsightRequest) async throws -> JournalInsight
}
Nothing here imports Foundation Models or a vendor SDK. A SwiftUI view model can depend on JournalInsightGenerating, and previews or unit tests can replace it without loading a model. If you are tightening concurrency boundaries, the reasoning in Swift 6 concurrency-safe type choices applies directly: immutable Sendable request and response values are much easier to move between isolation domains.
2. Understand What Apple Is Standardizing
Apple’s WWDC26 provider session presents two key protocols. LanguageModel describes capabilities and supplies configuration; LanguageModelExecutor prepares resources and streams generation. LanguageModelSession remains the application-facing conversation API.
Apple shows the same session shape with several model implementations:
import FoundationModels
import MLXFoundationModels
// Apple's on-device model
let model = SystemLanguageModel()
// Alternatives shown by Apple for iOS 27-era SDKs:
// let model = PrivateCloudComputeLanguageModel()
// let model = try await CoreAILanguageModel(resourcesAt: modelURL)
// let model = MLXLanguageModel(modelID: "mlx-community/my-model")
let session = LanguageModelSession(model: model)
let response = try await session.respond(to: "Summarize this journal entry")
print(response.content)
This is verified preview syntax from Apple’s video, not a promise that every initializer will remain identical in the final SDK. Provider packages may also require their own imports, authentication setup, and model-specific configuration.
The abstraction is valuable because sessions retain framework behaviors above the executor: transcripts, tools, structured generation, generation options, and usage accounting. It does not mean models are behaviorally interchangeable. A tiny local model and a reasoning cloud model can conform to the same protocol while differing in context size, modalities, tool support, latency, safety behavior, and output quality.
Treat LanguageModel as a transport-compatible interface, not a semantic compatibility guarantee.
3. Put Routing Policy in One Place
Do not scatter if model.isAvailable checks through view models. Create a route that expresses what the product chose and why:
enum AIBackend: String, Sendable {
case appleOnDevice
case privateCloudCompute
case bundledLocal
case thirdPartyCloud
}
struct AIRoute: Sendable, Equatable {
let backend: AIBackend
let reason: String
}
struct AIRoutingContext: Sendable {
let containsSensitiveData: Bool
let allowsCloudProcessing: Bool
let isConstrainedNetwork: Bool
let estimatedInputTokens: Int
}
protocol AIRouting: Sendable {
func route(for context: AIRoutingContext) async throws -> AIRoute
}
Then make the decision order explicit. A reasonable policy might be:
- Reject cloud routes when the user disabled cloud processing.
- Prefer the system model for supported, modest tasks on eligible devices.
- Use an approved bundled model when offline operation matters and its quality passes evaluation.
- Use PCC when its eligibility, entitlement, capability, and privacy requirements fit.
- Use an authorized third-party service for capabilities the other routes cannot provide.
- Fall back to a deterministic non-AI experience when no acceptable route exists.
For the shipping on-device API, Apple documents an availability check:
import FoundationModels
func onDeviceAvailability() -> SystemLanguageModel.Availability {
SystemLanguageModel.default.availability
}
func canOfferOnDeviceInsights() -> Bool {
switch onDeviceAvailability() {
case .available:
return true
case .unavailable(.deviceNotEligible),
.unavailable(.modelNotReady):
return false
case .unavailable:
return false
}
}
Availability is runtime state, not installation-time truth. The device may be ineligible, the model may still be downloading, a locale may be unsupported, or policy may prohibit a route. Re-evaluate when the user retries and present an ordinary fallback rather than an alarming error.
4. Keep the Framework Adapter Narrow
Your feature service should orchestrate routing and delegate generation. The following is architecture pseudocode: AnyJournalInsightEngine is an application type, not an Apple API, and concrete engine construction depends on the final SDK and provider packages.
// Architecture pseudocode — not intended to compile as written.
actor RoutedJournalInsightService: JournalInsightGenerating {
private let router: any AIRouting
private let engines: [AIBackend: AnyJournalInsightEngine]
init(
router: any AIRouting,
engines: [AIBackend: AnyJournalInsightEngine]
) {
self.router = router
self.engines = engines
}
func generateInsight(for request: InsightRequest) async throws -> JournalInsight {
let context = AIRoutingContext(
containsSensitiveData: request.containsSensitiveData,
allowsCloudProcessing: userPreferenceAllowsCloud(),
isConstrainedNetwork: currentNetworkIsConstrained(),
estimatedInputTokens: estimateTokens(request.entry)
)
let route = try await router.route(for: context)
guard let engine = engines[route.backend] else {
throw AIServiceError.routeNotConfigured(route.backend)
}
do {
return try await engine.generateInsight(for: request)
} catch let error as AIServiceError where error.isRetryable {
let fallback = try await router.fallback(after: route, for: context)
guard fallback != route,
let fallbackEngine = engines[fallback.backend] else {
throw error
}
return try await fallbackEngine.generateInsight(for: request)
}
}
}
Use an actor when the service owns mutable sessions, quota counters, or route state. Do not create unstructured tasks to hide isolation errors. The UI should call the service from a cancellable task and update observable state on the main actor; why async work can still freeze an iOS UI covers that execution boundary in detail.
5. Preserve Structured Output and Tool Semantics
Free-form text is a fragile application boundary. Foundation Models supports generated Swift values through @Generable, letting the framework constrain output to a schema:
import FoundationModels
@Generable
struct GeneratedJournalInsight {
@Guide(description: "A calm title containing at most six words")
let title: String
@Guide(description: "One lowercase mood label")
let mood: String
@Guide(description: "Two to four short themes", .count(2...4))
let themes: [String]
}
func generateOnDevice(entry: String) async throws -> JournalInsight {
let session = LanguageModelSession(
model: SystemLanguageModel.default,
instructions: "Analyze a journal entry without inventing facts."
)
let response = try await session.respond(
to: "Analyze this entry:\n\(entry)",
generating: GeneratedJournalInsight.self
)
let value = response.content
return JournalInsight(
title: value.title,
mood: value.mood,
themes: value.themes
)
}
This example uses the established Foundation Models structured-generation shape. Reconfirm @Guide constraints and overloads against your deployment SDK.
A provider route should pass the same response schema through the common session where the model advertises support. If a provider cannot honor structured output, do not silently prompt for JSON and pretend the guarantee is equivalent. Either add a validated decoding adapter with bounded repair, or mark that backend ineligible for the feature.
Tools need the same discipline. A tool is application code with side effects, authorization, and input validation; the model merely proposes a call. Define a provider-neutral tool catalog, expose only tools supported by the chosen route, and require confirmation for destructive or externally visible actions. Never let a fallback retry repeat a payment, message, or write operation without idempotency protection.
6. Let the Application Own Conversation State
LanguageModelSession holds context and records a Transcript containing instructions, prompts, responses, tool calls, and tool results. Apple documents creating a session from an existing transcript, which makes migration between compatible models possible.
That does not make an in-memory session your database. Persist an application conversation record with:
- a stable conversation ID and schema version;
- user-visible messages;
- tool calls and outcomes that are safe to restore;
- the selected route and model version;
- consent and retention metadata;
- usage and evaluation identifiers, not provider secrets.
Rebuild a framework transcript only at the adapter boundary. Before moving it to another model, filter provider-only metadata, unsupported attachment or segment types, hidden reasoning, and tool definitions the destination cannot execute. Summarize older turns when the context window is close to its limit, but retain the original user record for audit and display.
The iOS 27 beta documentation also exposes accumulated LanguageModelSession.Usage, while SystemLanguageModel reports context size and can count tokens. Use those values when available instead of estimating characters. Usage belongs in telemetry with route, latency, cancellation, and outcome—not with raw private prompts.
7. Make Privacy, Cost, and Authentication Hard Constraints
Routing is a policy engine, not a leaderboard.
Privacy: On-device processing should be the default for sensitive content when it can meet the task. PCC is server-side processing with Apple’s privacy architecture, but it is still a different data path that deserves accurate disclosure. A third-party cloud route must follow that provider’s retention and training terms. Obtain meaningful user consent before moving personal content off-device.
Latency: Measure time to first token and completion separately. Prewarming can improve the first on-device request, but consumes resources; use it near a likely interaction, not at every launch. Local open-source models also add download size, memory pressure, and thermal cost.
Cost: Track prompt, completion, cached, and reasoning tokens where the provider reports them. Enforce per-request and daily budgets on the server for paid routes. Never trust the client as the only quota boundary.
Authentication: Do not ship a permanent API key in an IPA. Apple’s provider guidance recommends OAuth and secure storage such as Keychain for appropriate user-scoped credentials. For app-owned billing, put the long-lived provider credential on your server and issue narrowly scoped, expiring access to the app. The provider adapter may request a token; feature code should never see it.
Apple’s WWDC26 guidance describes PrivateCloudComputeLanguageModel without API keys or account setup, but access is not unconditional. The published program includes eligibility and entitlement requirements, and Apple currently describes an App Store Small Business Program path with a total first-time-download threshold. Treat those as launch dependencies and verify current program terms in the WWDC26 machine-learning guide.
8. Design Errors Around User Recovery
Provider SDK errors are too detailed for feature code and too unstable for analytics. Normalize them:
enum AIServiceError: Error, Sendable, Equatable {
case unavailable
case unsupportedCapability
case authenticationRequired
case rateLimited(retryAfter: Duration?)
case contextTooLarge
case unsafeContent
case invalidStructuredOutput
case networkUnavailable
case cancelled
case providerFailure
var isRetryable: Bool {
switch self {
case .rateLimited, .networkUnavailable, .providerFailure:
return true
default:
return false
}
}
}
Preserve the underlying error in private diagnostics, but show an action: retry, shorten the input, sign in, download the model, enable a supported language, or continue without AI. Cancellation is not a failure and should not trigger another provider. Safety failures should not be routed repeatedly in an attempt to find a model with looser guardrails.
Retry only idempotent generation, apply jittered backoff, honor Retry-After, and limit the number of provider transitions. A fallback chain that quietly calls four paid services is neither resilient nor predictable.
9. Test Policy Deterministically, Then Evaluate Quality
Start with a deterministic double at the feature boundary:
struct StubJournalInsightGenerator: JournalInsightGenerating {
var result: Result<JournalInsight, AIServiceError>
func generateInsight(for request: InsightRequest) async throws -> JournalInsight {
try result.get()
}
}
Unit-test routing as a matrix: sensitive versus ordinary data, cloud consent on or off, model ready or unavailable, constrained network, context overflow, expired authentication, rate limiting, cancellation, and tool side effects. Assert the chosen route and fallback, not the model’s prose.
Semantic quality needs evaluations. Keep a versioned dataset of representative prompts, expected properties, prohibited behaviors, locale cases, tool traces, and structured-output validity. Run it against each model and prompt version before enabling a route. Score factuality, schema compliance, task success, latency, and cost separately; a single average hides dangerous regressions.
Apple introduced an Evaluations framework at WWDC26 for these model-dependent checks. It complements rather than replaces unit tests. Pin evaluation inputs, record OS and model variants, and set release thresholds. Models can change with operating-system updates even when your app binary does not.
10. Adopt the Layer Without Rewriting Everything
Do not begin by implementing every provider. Extract one valuable feature in four steps:
- Move its request and response into provider-neutral
Sendablevalues. - Put the existing implementation behind a feature protocol.
- Add route metadata, privacy consent, and normalized errors before adding a fallback.
- Enable a second backend only after both pass the same evaluation suite.
Keep model-specific prompt tuning inside adapters or versioned profiles. Keep product instructions—tone, safety boundaries, required fields—in an application-owned specification. Dynamic Profiles in the iOS 27-era framework may help compose models, tools, and instructions within a continuous session, but avoid making a beta convenience the only representation of product policy.
Measure adoption with privacy-preserving signals: route selected, availability reason, latency bucket, token count, schema validity, fallback count, cancellation, and user acceptance. Never log raw prompts by default. Make remote route disablement possible so a provider incident or pricing change does not require an App Store release.
Key Takeaways
- Depend on a product capability, not
SystemLanguageModelor a vendor SDK. - Centralize routing across privacy, capability, availability, latency, cost, and consent.
- Use the common
LanguageModelSessionsurface while acknowledging that model behavior differs. - Preserve schemas and tool safety across routes; never downgrade guarantees silently.
- Let the application own durable conversation state and rebuild transcripts at the boundary.
- Keep secrets off the client, normalize provider errors, and bound every retry and fallback.
- Use deterministic tests for policy and evaluations for model quality.
- Treat WWDC26 and iOS 27 APIs as beta until Apple ships final SDKs and terms.
Conclusion
Provider independence does not come from wrapping four SDKs in one giant protocol. It comes from owning the decisions vendors cannot make for you: what the feature promises, where private data may travel, what failures the user can recover from, how tools are authorized, and what quality is acceptable.
Foundation Models’ expanding LanguageModel abstraction removes a large amount of mechanical integration work. Use that common surface, but keep route policy, conversation ownership, authentication boundaries, and evaluations in your architecture. Then changing a model becomes an infrastructure decision instead of a product rewrite—which is exactly where it belongs.