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") + } +}