Revive Apple Intelligence provider (Phase 1 — on-device)

Apple Intelligence / Foundation Models is now genuinely available on
this machine (macOS 27 beta 4) — it was reverted back in June
(f63226b) when it wasn't. Ports the reverted AppleFoundationProvider
forward onto 2.4.3, adapted for everything that's changed since:
PolyForm license headers, current AIProvider protocol shape, current
Settings.Provider/ProviderRegistry/CreditsView/SettingsView structure.

Fixes a real bug found via live testing: LanguageModelSession.
GenerationError was deprecated in macOS 27.0 in favor of a new
LanguageModelError type. On a macOS 27+ runtime, generation failures
now throw LanguageModelError, not GenerationError, so the original
error-mapping catch never matched and Apple's raw error text leaked
to the user instead of oAI's friendly message. Now dispatches to
whichever type the runtime actually throws, gated with
@available(macOS 27.0, *), keeping the old GenerationError path as
a fallback for macOS 26.x (the app's actual deployment target).

Confirmed end-to-end in the live app: provider selectable, Settings
shows a live "Available" badge, chat header shows correct branding,
and — a real, expected Phase 1 limitation — oAI's default system
prompt (active Agent Skills + MCP tool guidance, ~16K tokens on this
machine) exceeds the on-device model's 4K context window on the
very first message. The friendly error message now correctly reports
this instead of Apple's raw string. Tool calling remains out of scope
until Phase 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 09:46:17 +02:00
co-authored by Claude Sonnet 5
parent f3c6271c9e
commit 30fbb4162e
9 changed files with 337 additions and 3 deletions
+8 -3
View File
@@ -56,17 +56,22 @@ struct Settings: Codable {
case anthropic
case openai
case ollama
case appleOnDevice = "apple_on_device"
var displayName: String {
rawValue.capitalized
switch self {
case .appleOnDevice: return "Apple Intelligence"
default: return rawValue.capitalized
}
}
var iconName: String {
switch self {
case .openrouter: return "network"
case .anthropic: return "brain"
case .openai: return "sparkles"
case .ollama: return "server.rack"
case .appleOnDevice: return "apple.logo"
}
}
}
+228
View File
@@ -0,0 +1,228 @@
//
// AppleFoundationProvider.swift
// oAI
//
// Apple Foundation Models provider (on-device Apple Intelligence)
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling oAI or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import FoundationModels
import os
final class AppleFoundationProvider: AIProvider {
let name = "Apple Intelligence"
let capabilities = ProviderCapabilities(
supportsStreaming: true,
supportsVision: false,
supportsTools: false,
supportsOnlineSearch: false,
maxContextLength: 4096
)
// MARK: - Models
func listModels() async throws -> [ModelInfo] {
[
ModelInfo(
id: "apple-on-device",
name: "Apple On-Device",
description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.",
contextLength: 4096,
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
capabilities: ModelInfo.ModelCapabilities(
vision: false,
tools: false,
online: false
)
)
]
}
func getModel(_ id: String) async throws -> ModelInfo? {
try await listModels().first { $0.id == id }
}
func getCredits() async throws -> Credits? { nil }
// MARK: - Streaming chat
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let session = try self.makeSession(for: request)
let prompt = self.lastUserMessage(from: request)
// streamResponse(to: String) ResponseStream<String>
// Each snapshot.content is the full accumulated text so far (snapshot model).
// We compute deltas by comparing each snapshot to the previous.
let stream = session.streamResponse(to: prompt)
var lastContent = ""
for try await snapshot in stream {
let current = snapshot.content
if current.count > lastContent.count {
let delta = String(current.dropFirst(lastContent.count))
continuation.yield(StreamChunk(
id: UUID().uuidString,
model: request.model,
delta: StreamChunk.Delta(content: delta, role: "assistant"),
finishReason: nil,
usage: nil
))
lastContent = current
}
}
continuation.yield(StreamChunk(
id: UUID().uuidString,
model: request.model,
delta: StreamChunk.Delta(content: nil, role: nil),
finishReason: "stop",
usage: nil
))
continuation.finish()
} catch {
continuation.finish(throwing: self.mapProviderError(error))
}
}
}
}
// MARK: - Non-streaming chat
func chat(request: ChatRequest) async throws -> ChatResponse {
let session = try makeSession(for: request)
let prompt = lastUserMessage(from: request)
do {
let response: LanguageModelSession.Response<String> = try await session.respond(to: prompt)
return ChatResponse(
id: UUID().uuidString,
model: request.model,
content: response.content,
role: "assistant",
finishReason: "stop",
usage: nil,
created: Date()
)
} catch {
throw mapProviderError(error)
}
}
// MARK: - Tool messages (not supported in Phase 1)
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.")
}
// MARK: - Session construction
private func makeSession(for request: ChatRequest) throws -> LanguageModelSession {
guard case .available = SystemLanguageModel.default.availability else {
throw availabilityError()
}
// Build instructions: system prompt + prior conversation turns as formatted text.
// Foundation Models sessions don't accept a message array we inject history inline.
var instructions = request.systemPrompt ?? ""
let priorMessages = request.messages.dropLast().filter { $0.role != .system }
if !priorMessages.isEmpty {
let history = priorMessages
.map { m -> String in
let label = m.role == .user ? "User" : "Assistant"
return "\(label): \(m.content)"
}
.joined(separator: "\n")
instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here."
}
return instructions.isEmpty
? LanguageModelSession()
: LanguageModelSession(instructions: instructions)
}
private func lastUserMessage(from request: ChatRequest) -> String {
request.messages.last(where: { $0.role == .user })?.content ?? ""
}
// MARK: - Error mapping
private func availabilityError() -> Error {
switch SystemLanguageModel.default.availability {
case .unavailable(.deviceNotEligible):
return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.")
case .unavailable(.appleIntelligenceNotEnabled):
return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.")
case .unavailable(.modelNotReady):
return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.")
default:
return ProviderError.unknown("Apple Intelligence is not available on this device.")
}
}
/// Dispatches to whichever generation-error type the running OS actually throws.
/// `LanguageModelSession.GenerationError` was deprecated in macOS 27.0 in favor of the new
/// top-level `LanguageModelError` on a macOS 27+ runtime, generation failures come through
/// as `LanguageModelError`, not `GenerationError`, even though the app's deployment target
/// (26.2) still needs to handle macOS 26.x runtimes throwing the older type.
private func mapProviderError(_ error: Error) -> Error {
if #available(macOS 27.0, *), let lmError = error as? LanguageModelError {
return mapLanguageModelError(lmError)
}
if let genError = error as? LanguageModelSession.GenerationError {
return mapGenerationError(genError)
}
return error
}
@available(macOS 27.0, *)
private func mapLanguageModelError(_ error: LanguageModelError) -> Error {
switch error {
case .contextSizeExceeded(let detail):
return ProviderError.unknown("Apple Intelligence context limit exceeded (\(detail.tokenCount)/\(detail.contextSize) tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
case .rateLimited:
return ProviderError.rateLimitExceeded
case .guardrailViolation:
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
case .timeout:
return ProviderError.timeout
default:
return error
}
}
/// macOS 26.x fallback `GenerationError` is deprecated on macOS 27+ but still the type
/// actually thrown on 26.x runtimes, which the 26.2 deployment target must keep supporting.
private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error {
switch error {
case .exceededContextWindowSize:
return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
case .rateLimited:
return ProviderError.rateLimitExceeded
case .guardrailViolation:
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
default:
return error
}
}
}
+5
View File
@@ -67,6 +67,9 @@ class ProviderRegistry {
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
case .appleOnDevice:
provider = AppleFoundationProvider()
}
// Cache and return
@@ -104,6 +107,8 @@ class ProviderRegistry {
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
case .ollama:
return settings.ollamaConfigured
case .appleOnDevice:
return true // no API key needed
}
}
@@ -62,6 +62,7 @@ extension Color {
case .anthropic: return Color(hex: "#d4895a") // Orange
case .openai: return Color(hex: "#10a37f") // Green
case .ollama: return Color(hex: "#ffffff") // White
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
}
}
+2
View File
@@ -422,6 +422,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
// Apple Foundation Models
if modelId.hasPrefix("apple-") { return .appleOnDevice }
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
if modelId.contains("/") { return .openrouter }
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
+11
View File
@@ -78,6 +78,17 @@ struct CreditsView: View {
.font(.system(size: 40))
.foregroundColor(.green)
.padding(.top)
case .appleOnDevice:
Text("Apple Intelligence")
.font(.headline)
Text("On-device and free — no credits or API key needed.")
.font(.body)
.foregroundColor(.secondary)
Image(systemName: "apple.logo")
.font(.system(size: 40))
.foregroundColor(.secondary)
.padding(.top)
}
}
+46
View File
@@ -23,6 +23,7 @@
import SwiftUI
import UniformTypeIdentifiers
import FoundationModels
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@@ -319,6 +320,29 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// Apple Intelligence
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Apple Intelligence")
formSection {
row("Status") {
appleIntelligenceStatusBadge
}
rowDivider()
row("Model") {
Text("On-Device (4K context)")
.foregroundStyle(.secondary)
}
rowDivider()
row("") {
Button("Open Apple Intelligence Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
NSWorkspace.shared.open(url)
}
}
}
}
}
// Features
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Features")
@@ -3089,6 +3113,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: .now)
}
@ViewBuilder
private var appleIntelligenceStatusBadge: some View {
let availability = SystemLanguageModel.default.availability
switch availability {
case .available:
Label("Available", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .unavailable(.deviceNotEligible):
Label("Not supported on this Mac", systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
case .unavailable(.appleIntelligenceNotEnabled):
Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
case .unavailable(.modelNotReady):
Label("Model downloading…", systemImage: "arrow.down.circle.fill")
.foregroundStyle(.orange)
default:
Label("Unavailable", systemImage: "questionmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
#Preview {
@@ -64,6 +64,11 @@ struct ChatViewModelPureLogicTests {
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
}
@Test("apple- prefixed model is inferred as Apple on-device")
func infersAppleOnDevice() {
#expect(ChatViewModel.inferProvider(from: "apple-on-device") == .appleOnDevice)
}
// MARK: - calculateCost
@Test("Base prompt and completion cost with no cache usage")
+31
View File
@@ -0,0 +1,31 @@
//
// SettingsProviderTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import oAI
@Suite("Settings.Provider display metadata")
struct SettingsProviderTests {
@Test("Apple on-device gets a dedicated display name, not a raw-value capitalization")
func appleOnDeviceDisplayName() {
#expect(Settings.Provider.appleOnDevice.displayName == "Apple Intelligence")
}
@Test("Apple on-device uses the Apple logo icon")
func appleOnDeviceIconName() {
#expect(Settings.Provider.appleOnDevice.iconName == "apple.logo")
}
@Test("Other providers still fall back to a capitalized raw value")
func otherProvidersUseCapitalizedRawValue() {
#expect(Settings.Provider.openrouter.displayName == "Openrouter")
#expect(Settings.Provider.anthropic.displayName == "Anthropic")
#expect(Settings.Provider.openai.displayName == "Openai")
#expect(Settings.Provider.ollama.displayName == "Ollama")
}
}