From 30fbb4162e4a9f24b98b833d5cea1bbcec147048 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:46:17 +0200 Subject: [PATCH] =?UTF-8?q?Revive=20Apple=20Intelligence=20provider=20(Pha?= =?UTF-8?q?se=201=20=E2=80=94=20on-device)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- oAI/Models/Settings.swift | 11 +- oAI/Providers/AppleFoundationProvider.swift | 228 ++++++++++++++++++ oAI/Providers/ProviderRegistry.swift | 5 + .../Extensions/Color+Extensions.swift | 1 + oAI/ViewModels/ChatViewModel.swift | 2 + oAI/Views/Screens/CreditsView.swift | 11 + oAI/Views/Screens/SettingsView.swift | 46 ++++ oAITests/ChatViewModelPureLogicTests.swift | 5 + oAITests/SettingsProviderTests.swift | 31 +++ 9 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 oAI/Providers/AppleFoundationProvider.swift create mode 100644 oAITests/SettingsProviderTests.swift diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index b4cfd40..effe640 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -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" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift new file mode 100644 index 0000000..9071b01 --- /dev/null +++ b/oAI/Providers/AppleFoundationProvider.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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 { + AsyncThrowingStream { continuation in + Task { + do { + let session = try self.makeSession(for: request) + let prompt = self.lastUserMessage(from: request) + + // streamResponse(to: String) → ResponseStream + // 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 = 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 + } + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index cf6c5ee..e5d9a2a 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -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 } } diff --git a/oAI/Utilities/Extensions/Color+Extensions.swift b/oAI/Utilities/Extensions/Color+Extensions.swift index 95eec0a..e0579d7 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -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 } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 3af0d2e..0687d70 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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") diff --git a/oAI/Views/Screens/CreditsView.swift b/oAI/Views/Screens/CreditsView.swift index 7008925..b9b0d84 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -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) } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 0f3c8e7..4e0551f 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -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 { diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index c6a163f..36e4a5d 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -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") diff --git a/oAITests/SettingsProviderTests.swift b/oAITests/SettingsProviderTests.swift new file mode 100644 index 0000000..ca2255a --- /dev/null +++ b/oAITests/SettingsProviderTests.swift @@ -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") + } +}