2.4.3 #9
@@ -56,17 +56,22 @@ struct Settings: Codable {
|
|||||||
case anthropic
|
case anthropic
|
||||||
case openai
|
case openai
|
||||||
case ollama
|
case ollama
|
||||||
|
case appleOnDevice = "apple_on_device"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
rawValue.capitalized
|
switch self {
|
||||||
|
case .appleOnDevice: return "Apple Intelligence"
|
||||||
|
default: return rawValue.capitalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openrouter: return "network"
|
case .openrouter: return "network"
|
||||||
case .anthropic: return "brain"
|
case .anthropic: return "brain"
|
||||||
case .openai: return "sparkles"
|
case .openai: return "sparkles"
|
||||||
case .ollama: return "server.rack"
|
case .ollama: return "server.rack"
|
||||||
|
case .appleOnDevice: return "apple.logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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<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()
|
||||||
|
)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,9 @@ class ProviderRegistry {
|
|||||||
|
|
||||||
case .ollama:
|
case .ollama:
|
||||||
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
||||||
|
|
||||||
|
case .appleOnDevice:
|
||||||
|
provider = AppleFoundationProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache and return
|
// Cache and return
|
||||||
@@ -104,6 +107,8 @@ class ProviderRegistry {
|
|||||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||||
case .ollama:
|
case .ollama:
|
||||||
return settings.ollamaConfigured
|
return settings.ollamaConfigured
|
||||||
|
case .appleOnDevice:
|
||||||
|
return true // no API key needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ extension Color {
|
|||||||
case .anthropic: return Color(hex: "#d4895a") // Orange
|
case .anthropic: return Color(hex: "#d4895a") // Orange
|
||||||
case .openai: return Color(hex: "#10a37f") // Green
|
case .openai: return Color(hex: "#10a37f") // Green
|
||||||
case .ollama: return Color(hex: "#ffffff") // White
|
case .ollama: return Color(hex: "#ffffff") // White
|
||||||
|
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
|
||||||
|
|
||||||
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
|
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")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ struct CreditsView: View {
|
|||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.padding(.top)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@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
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Features")
|
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
|
formatter.unitsStyle = .full
|
||||||
return formatter.localizedString(for: date, relativeTo: .now)
|
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 {
|
#Preview {
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ struct ChatViewModelPureLogicTests {
|
|||||||
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
|
#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
|
// MARK: - calculateCost
|
||||||
|
|
||||||
@Test("Base prompt and completion cost with no cache usage")
|
@Test("Base prompt and completion cost with no cache usage")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user