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:
@@ -56,17 +56,22 @@ struct Settings: Codable {
|
|||||||
case anthropic
|
case anthropic
|
||||||
case openai
|
case openai
|
||||||
case ollama
|
case ollama
|
||||||
|
case appleOnDevice = "apple_on_device"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
rawValue.capitalized
|
switch self {
|
||||||
|
case .appleOnDevice: return "Apple Intelligence"
|
||||||
|
default: return rawValue.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openrouter: return "network"
|
case .openrouter: return "network"
|
||||||
case .anthropic: return "brain"
|
case .anthropic: return "brain"
|
||||||
case .openai: return "sparkles"
|
case .openai: return "sparkles"
|
||||||
case .ollama: return "server.rack"
|
case .ollama: return "server.rack"
|
||||||
|
case .appleOnDevice: return "apple.logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ class ProviderRegistry {
|
|||||||
|
|
||||||
case .ollama:
|
case .ollama:
|
||||||
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
||||||
|
|
||||||
|
case .appleOnDevice:
|
||||||
|
provider = AppleFoundationProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache and return
|
// Cache and return
|
||||||
@@ -104,6 +107,8 @@ class ProviderRegistry {
|
|||||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||||
case .ollama:
|
case .ollama:
|
||||||
return settings.ollamaConfigured
|
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 .anthropic: return Color(hex: "#d4895a") // Orange
|
||||||
case .openai: return Color(hex: "#10a37f") // Green
|
case .openai: return Color(hex: "#10a37f") // Green
|
||||||
case .ollama: return Color(hex: "#ffffff") // White
|
case .ollama: return Color(hex: "#ffffff") // White
|
||||||
|
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
|
||||||
|
|
||||||
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
|
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")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ struct CreditsView: View {
|
|||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.padding(.top)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@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
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Features")
|
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
|
formatter.unitsStyle = .full
|
||||||
return formatter.localizedString(for: date, relativeTo: .now)
|
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 {
|
#Preview {
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ struct ChatViewModelPureLogicTests {
|
|||||||
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
|
#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
|
// MARK: - calculateCost
|
||||||
|
|
||||||
@Test("Base prompt and completion cost with no cache usage")
|
@Test("Base prompt and completion cost with no cache usage")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user