diff --git a/oAI/Models/Settings.swift b/oAI/Models/Settings.swift index af46f2c..f2358d2 100644 --- a/oAI/Models/Settings.swift +++ b/oAI/Models/Settings.swift @@ -58,25 +58,17 @@ struct Settings: Codable { case anthropic case openai case ollama - case appleOnDevice = "apple_on_device" - + var displayName: String { - switch self { - case .openrouter: return "OpenRouter" - case .anthropic: return "Anthropic" - case .openai: return "OpenAI" - case .ollama: return "Ollama" - case .appleOnDevice: return "Apple Intelligence" - } + rawValue.capitalized } - + var iconName: String { switch self { - case .openrouter: return "network" - case .anthropic: return "brain" - case .openai: return "sparkles" - case .ollama: return "server.rack" - case .appleOnDevice: return "apple.logo" + case .openrouter: return "network" + case .anthropic: return "brain" + case .openai: return "sparkles" + case .ollama: return "server.rack" } } } diff --git a/oAI/Providers/AppleFoundationProvider.swift b/oAI/Providers/AppleFoundationProvider.swift deleted file mode 100644 index 340f0b7..0000000 --- a/oAI/Providers/AppleFoundationProvider.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// 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 e0d3aa6..9785a62 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -69,9 +69,6 @@ class ProviderRegistry { case .ollama: provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL) - - case .appleOnDevice: - provider = AppleFoundationProvider() } // Cache and return @@ -109,8 +106,6 @@ 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 6c790a1..e36d750 100644 --- a/oAI/Utilities/Extensions/Color+Extensions.swift +++ b/oAI/Utilities/Extensions/Color+Extensions.swift @@ -60,11 +60,10 @@ 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 .appleOnDevice: return Color(hex: "#636366") // Apple grey + 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 } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 31695ba..d398f93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -424,8 +424,6 @@ 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 a3a2298..b60bdd4 100644 --- a/oAI/Views/Screens/CreditsView.swift +++ b/oAI/Views/Screens/CreditsView.swift @@ -80,17 +80,6 @@ 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 67d4c72..aed4728 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -25,7 +25,6 @@ import SwiftUI import UniformTypeIdentifiers -import FoundationModels struct SettingsView: View { @Environment(\.dismiss) var dismiss @@ -307,29 +306,6 @@ 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") @@ -2673,28 +2649,6 @@ 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 {