Files
oai-swift/oAI/Providers/AppleFoundationProvider.swift
rune f3a0c45331 Add Apple Intelligence provider (Phase 1 — on-device)
- New AppleFoundationProvider using FoundationModels framework (macOS 27+)
- Streaming via streamResponse(to:) → ResponseStream<String> snapshot deltas
- Session built with system prompt + conversation history injected as instructions text
- Full error mapping: context exceeded, guardrail violation, rate limit, availability states
- Settings.Provider.appleOnDevice case wired through ProviderRegistry, Color+Extensions, CreditsView
- inferProvider() detects "apple-" prefix model IDs
- Settings → General: Apple Intelligence section with live availability badge and deep link to System Settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:36:55 +02:00

196 lines
7.5 KiB
Swift

//
// 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 <https://www.gnu.org/licenses/>.
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 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<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()
)
}
// 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
}
}
}