diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift
index f2358d2..af46f2c 100644
--- a/oAI/Models/Settings.swift
+++ b/oAI/Models/Settings.swift
@@ -58,17 +58,25 @@ struct Settings: Codable {
case anthropic
case openai
case ollama
-
+ case appleOnDevice = "apple_on_device"
+
var displayName: String {
- rawValue.capitalized
+ switch self {
+ case .openrouter: return "OpenRouter"
+ case .anthropic: return "Anthropic"
+ case .openai: return "OpenAI"
+ case .ollama: return "Ollama"
+ case .appleOnDevice: return "Apple Intelligence"
+ }
}
-
+
var iconName: String {
switch self {
- case .openrouter: return "network"
- case .anthropic: return "brain"
- case .openai: return "sparkles"
- case .ollama: return "server.rack"
+ 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..340f0b7
--- /dev/null
+++ b/oAI/Providers/AppleFoundationProvider.swift
@@ -0,0 +1,195 @@
+//
+// AppleFoundationProvider.swift
+// oAI
+//
+// Apple Foundation Models provider (on-device Apple Intelligence)
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Rune Olsen
+//
+// This file is part of oAI.
+//
+// oAI is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// oAI is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
+// Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public
+// License along with oAI. If not, see .
+
+
+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 let genError as LanguageModelSession.GenerationError {
+ continuation.finish(throwing: self.mapGenerationError(genError))
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ // MARK: - Non-streaming chat
+
+ func chat(request: ChatRequest) async throws -> ChatResponse {
+ let session = try makeSession(for: request)
+ let prompt = lastUserMessage(from: request)
+ 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()
+ )
+ }
+
+ // 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.")
+ }
+ }
+
+ 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 9785a62..e0d3aa6 100644
--- a/oAI/Providers/ProviderRegistry.swift
+++ b/oAI/Providers/ProviderRegistry.swift
@@ -69,6 +69,9 @@ class ProviderRegistry {
case .ollama:
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
+
+ case .appleOnDevice:
+ provider = AppleFoundationProvider()
}
// Cache and return
@@ -106,6 +109,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 e36d750..6c790a1 100644
--- a/oAI/Utilities/Extensions/Color+Extensions.swift
+++ b/oAI/Utilities/Extensions/Color+Extensions.swift
@@ -60,10 +60,11 @@ extension Color {
static func providerColor(_ provider: Settings.Provider) -> Color {
switch provider {
- case .openrouter: return Color(hex: "#7c3aed") // Purple
- case .anthropic: return Color(hex: "#d4895a") // Orange
- case .openai: return Color(hex: "#10a37f") // Green
- case .ollama: return Color(hex: "#ffffff") // White
+ case .openrouter: return Color(hex: "#7c3aed") // Purple
+ 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 d398f93..31695ba 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -424,6 +424,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
private 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 b60bdd4..a3a2298 100644
--- a/oAI/Views/Screens/CreditsView.swift
+++ b/oAI/Views/Screens/CreditsView.swift
@@ -80,6 +80,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 aed4728..67d4c72 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -25,6 +25,7 @@
import SwiftUI
import UniformTypeIdentifiers
+import FoundationModels
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@@ -306,6 +307,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")
@@ -2649,6 +2673,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 {