f3a0c45331
- 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>
196 lines
7.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|