-
-
Notifications
You must be signed in to change notification settings - Fork 285
feat(ai-providers): add Apple Intelligence on-device provider for macOS 26+ #1554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,14 @@ enum AIProviderFactory { | |
| } | ||
| } | ||
|
|
||
| static func makeAppleIntelligenceProvider() -> ChatTransport { | ||
| let status = AppleIntelligenceAvailability.currentStatus() | ||
| if #available(macOS 26, *), status == .available { | ||
| return AppleIntelligenceTransport() | ||
| } | ||
| return UnavailableTransport(reason: status.statusText) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If an existing Apple Intelligence provider is resolved while Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| static func invalidateCache() { | ||
| cacheLock.withLock { $0.removeAll() } | ||
| } | ||
|
|
@@ -85,7 +93,7 @@ enum AIProviderFactory { | |
| switch config.type.authStyle { | ||
| case .apiKey, .optionalApiKey: | ||
| apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) | ||
| case .oauth, .none: | ||
| case .oauth, .none, .device: | ||
| apiKey = nil | ||
| } | ||
| let provider = createProvider(for: config, apiKey: apiKey) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // | ||
| // AppleIntelligenceAvailability.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import AppKit | ||
| import Foundation | ||
| #if canImport(FoundationModels) | ||
| import FoundationModels | ||
| #endif | ||
|
|
||
| enum AppleIntelligenceAvailability { | ||
| static func openSystemSettings() { | ||
| let identifiers = [ | ||
| "x-apple.systempreferences:com.apple.Siri-Settings.extension", | ||
| "x-apple.systempreferences:" | ||
| ] | ||
| for identifier in identifiers { | ||
| if let url = URL(string: identifier), NSWorkspace.shared.open(url) { | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| static func currentStatus() -> AppleIntelligenceStatus { | ||
| #if canImport(FoundationModels) | ||
| guard #available(macOS 26, *) else { return .osNotSupported } | ||
| return statusFromFramework() | ||
| #else | ||
| return .osNotSupported | ||
| #endif | ||
| } | ||
|
|
||
| #if canImport(FoundationModels) | ||
| @available(macOS 26, *) | ||
| private static func statusFromFramework() -> AppleIntelligenceStatus { | ||
| switch SystemLanguageModel.default.availability { | ||
| case .available: | ||
| return .available | ||
| case .unavailable(let reason): | ||
| switch reason { | ||
| case .deviceNotEligible: | ||
| return .deviceNotEligible | ||
| case .appleIntelligenceNotEnabled: | ||
| return .notEnabled | ||
| case .modelNotReady: | ||
| return .modelNotReady | ||
| @unknown default: | ||
| return .unknown | ||
| } | ||
| @unknown default: | ||
| return .unknown | ||
| } | ||
| } | ||
| #endif | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // | ||
| // AppleIntelligenceSchemaBuilder.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
| #if canImport(FoundationModels) | ||
| import FoundationModels | ||
|
|
||
| @available(macOS 26, *) | ||
| enum AppleIntelligenceSchemaBuilder { | ||
| static func buildGenerationSchema(from spec: ChatToolSpec) throws -> GenerationSchema { | ||
| let root = dynamicSchema(name: spec.name, description: spec.description, json: spec.inputSchema) | ||
| return try GenerationSchema(root: root, dependencies: []) | ||
| } | ||
|
|
||
| static func generatedContentToJsonValue(_ content: GeneratedContent) throws -> JsonValue { | ||
| guard let data = content.jsonString.data(using: .utf8) else { | ||
| throw AIProviderError.streamingFailed(String(localized: "Could not read tool arguments.")) | ||
| } | ||
| return try JSONDecoder().decode(JsonValue.self, from: data) | ||
| } | ||
|
|
||
| private static func dynamicSchema(name: String, description: String?, json: JsonValue) -> DynamicGenerationSchema { | ||
| switch primaryType(of: json) { | ||
| case "object": | ||
| return objectSchema(name: name, description: description, json: json) | ||
| case "array": | ||
| let items = json["items"] ?? .object(["type": .string("string")]) | ||
| let element = dynamicSchema(name: "\(name)_item", description: descriptionOf(items), json: items) | ||
| return DynamicGenerationSchema(arrayOf: element, minimumElements: nil, maximumElements: nil) | ||
| case "string": | ||
| let choices = (json["enum"]?.arrayValue ?? []).compactMap(\.stringValue) | ||
| if !choices.isEmpty { | ||
| return DynamicGenerationSchema(name: name, description: description, anyOf: choices) | ||
| } | ||
| return DynamicGenerationSchema(type: String.self, guides: []) | ||
| case "integer": | ||
| return DynamicGenerationSchema(type: Int.self, guides: []) | ||
| case "number": | ||
| return DynamicGenerationSchema(type: Double.self, guides: []) | ||
| case "boolean": | ||
| return DynamicGenerationSchema(type: Bool.self, guides: []) | ||
| default: | ||
| return DynamicGenerationSchema(type: String.self, guides: []) | ||
| } | ||
| } | ||
|
|
||
| private static func objectSchema(name: String, description: String?, json: JsonValue) -> DynamicGenerationSchema { | ||
| let propertySchemas = json["properties"]?.objectValue ?? [:] | ||
| let required = Set((json["required"]?.arrayValue ?? []).compactMap(\.stringValue)) | ||
| let properties = propertySchemas.map { key, valueSchema in | ||
| DynamicGenerationSchema.Property( | ||
| name: key, | ||
| description: descriptionOf(valueSchema), | ||
| schema: dynamicSchema(name: "\(name)_\(key)", description: descriptionOf(valueSchema), json: valueSchema), | ||
| isOptional: !required.contains(key) | ||
| ) | ||
| } | ||
| return DynamicGenerationSchema(name: name, description: description, properties: properties) | ||
| } | ||
|
|
||
| private static func primaryType(of json: JsonValue) -> String { | ||
| guard let typeValue = json["type"] else { return "object" } | ||
| if let single = typeValue.stringValue { return single } | ||
| if let array = typeValue.arrayValue { | ||
| return array.compactMap(\.stringValue).first(where: { $0 != "null" }) ?? "string" | ||
| } | ||
| return "object" | ||
| } | ||
|
|
||
| private static func descriptionOf(_ json: JsonValue) -> String? { | ||
| json["description"]?.stringValue | ||
| } | ||
| } | ||
| #endif |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // | ||
| // AppleIntelligenceStatus.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| enum AppleIntelligenceStatus: Sendable, Equatable { | ||
| case available | ||
| case osNotSupported | ||
| case deviceNotEligible | ||
| case notEnabled | ||
| case modelNotReady | ||
| case unknown | ||
|
|
||
| var isAvailable: Bool { self == .available } | ||
|
|
||
| var canOpenSystemSettings: Bool { self == .notEnabled } | ||
|
|
||
| var statusText: String { | ||
| switch self { | ||
| case .available: | ||
| return String(localized: "On-device. No API key, no network.") | ||
| case .osNotSupported: | ||
| return String(localized: "Requires macOS 26 or later.") | ||
| case .deviceNotEligible: | ||
| return String(localized: "Not available on this Mac. Apple Intelligence needs Apple silicon.") | ||
| case .notEnabled: | ||
| return String(localized: "Turn on Apple Intelligence in System Settings to use this.") | ||
| case .modelNotReady: | ||
| return String(localized: "The on-device model is still downloading. This finishes in the background.") | ||
| case .unknown: | ||
| return String(localized: "Apple Intelligence is not available right now.") | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // | ||
| // AppleIntelligenceTool.swift | ||
| // TablePro | ||
| // | ||
|
|
||
| import Foundation | ||
| #if canImport(FoundationModels) | ||
| import FoundationModels | ||
|
|
||
| @available(macOS 26, *) | ||
| final class AppleIntelligenceTool: FoundationModels.Tool { | ||
| typealias Arguments = GeneratedContent | ||
| typealias Output = String | ||
|
|
||
| let name: String | ||
| let description: String | ||
| let parameters: GenerationSchema | ||
|
|
||
| private let spec: ChatToolSpec | ||
| private let onCall: @Sendable (ChatToolSpec, GeneratedContent) async -> String | ||
|
|
||
| init( | ||
| spec: ChatToolSpec, | ||
| schema: GenerationSchema, | ||
| onCall: @escaping @Sendable (ChatToolSpec, GeneratedContent) async -> String | ||
| ) { | ||
| self.spec = spec | ||
| self.name = spec.name | ||
| self.description = spec.description | ||
| self.parameters = schema | ||
| self.onCall = onCall | ||
| } | ||
|
|
||
| func call(arguments: GeneratedContent) async throws -> String { | ||
| await onCall(spec, arguments) | ||
| } | ||
| } | ||
| #endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this target is built with an SDK where
canImport(FoundationModels)is false,AppleIntelligenceTransportis compiled out by the#ifin its own file, but this factory still references it. The weak-link flags do not help when the SDK module is unavailable, so pre-Xcode 26 builds fail instead of falling back toUnavailableTransport; wrap this branch in#if canImport(FoundationModels)and return the unavailable transport otherwise.Useful? React with 👍 / 👎.