Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions PulseLoop/Coach/Apple/AppleFoundationModelsClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import Foundation
#if canImport(FoundationModels)
import FoundationModels
#endif

/// Adapts the app's `ResponsesClient` protocol to Apple's on-device
/// FoundationModels (`LanguageModelSession`). Everything runs locally: no API
/// key, no network, fully private.
///
/// v1 is **tool-less** — it ignores the OpenAI function / `web_search` tool
/// specs in the request body and does one-shot generation over the pre-built
/// context packet. That covers summaries, daily check-ins and plain chat;
/// chart/action/memory tools stay disabled on-device. When the request carries
/// a strict JSON schema (`text.format`), it uses FoundationModels guided
/// generation to constrain the output, falling back to schema-in-prompt text if
/// guided generation can't be built for that schema (the orchestrator's
/// `JSONRepair` then recovers the object).
///
/// **Text-only:** the shipping FoundationModels SDK has no image-input API
/// (`PromptBuilder` accepts only text), so image attachments aren't supported
/// on-device. The Settings UI hides the image-input option when this provider is
/// selected, so image turns never reach this client.
///
/// Like `GeminiClient`, one instance covers a single agent turn and accumulates
/// the conversation across `send` calls so repair turns keep prior context.
final class AppleFoundationModelsClient: ResponsesClient, @unchecked Sendable {
/// Flattened system + developer text → session instructions.
private var systemText: String = ""
/// Ordered conversation turns (role: "user" | "assistant").
private var turns: [(role: String, text: String)] = []
/// responseId → assistant text, so a continuation turn can replay it.
private var storedAssistant: [String: String] = [:]

/// On-device context window is small; keep the assembled prompt within a
/// conservative character budget (~roughly 3–4k tokens) so we degrade to a
/// shorter answer rather than overflowing the model.
private let promptCharBudget = 12_000

init() {}

func send(requestBody: Data) async throws -> OpenAIResponse {
#if canImport(FoundationModels)
if #available(iOS 26.0, *) {
return try await generate(requestBody: requestBody)
} else {
throw ResponsesError.http(status: 503, body: "On-device AI requires iOS 26 or later.")
}
#else
throw ResponsesError.http(status: 503, body: "On-device AI is unavailable in this build.")
#endif
}

// MARK: - Request-body parsing (shared, framework-independent)

/// Splits the OpenAI-shaped body into instructions, the running transcript,
/// and the optional strict output schema. Mirrors `GeminiClient`'s setup so
/// behavior matches across providers.
private func ingest(_ requestBody: Data) throws -> (schema: [String: Any]?, schemaName: String) {
guard let req = try? JSONSerialization.jsonObject(with: requestBody) as? [String: Any] else {
throw ResponsesError.decoding("AppleFoundationModelsClient: invalid request body")
}
let input = req["input"] as? [[String: Any]] ?? []
let previousResponseId = req["previous_response_id"] as? String

if previousResponseId == nil {
setupConversation(from: input)
} else {
appendContinuation(previousId: previousResponseId!, input: input)
}

let format = (req["text"] as? [String: Any])?["format"] as? [String: Any]
let schema = format?["schema"] as? [String: Any]
let schemaName = (format?["name"] as? String) ?? "Response"
return (schema, schemaName)
}

private func setupConversation(from input: [[String: Any]]) {
turns = []
var systemParts: [String] = []
for item in input {
let role = item["role"] as? String ?? ""
let content = text(from: item)
if role == "system" || role == "developer" {
if !content.isEmpty { systemParts.append(content) }
} else if !content.isEmpty {
turns.append((role: role == "assistant" ? "assistant" : "user", text: content))
}
}
systemText = systemParts.joined(separator: "\n\n")
}

private func appendContinuation(previousId: String, input: [[String: Any]]) {
if let assistant = storedAssistant[previousId] {
turns.append((role: "assistant", text: assistant))
}
for item in input {
// Tool results can't be produced on-device (v1 is tool-less), but a
// repair/continuation turn still arrives as a plain message — fold it
// in as a user turn so context is preserved.
let content = text(from: item)
if let role = item["role"] as? String, !content.isEmpty {
turns.append((role: role == "assistant" ? "assistant" : "user", text: content))
} else if (item["type"] as? String) == "function_call_output",
let output = item["output"] as? String, !output.isEmpty {
turns.append((role: "user", text: "Tool result: \(output)"))
}
}
}

/// Pulls the text out of an input item. Text turns carry `content` as a plain
/// `String`; a multimodal item (which shouldn't reach this client — image
/// input is hidden on-device) carries the content-part array, from which we
/// keep only the `input_text`/`text` parts and drop images.
private func text(from item: [String: Any]) -> String {
if let content = item["content"] as? String { return content }
guard let parts = item["content"] as? [[String: Any]] else { return "" }
return parts.compactMap { part -> String? in
switch part["type"] as? String {
case "input_text", "text": return part["text"] as? String
default: return nil
}
}.joined(separator: "\n")
}

/// Renders the running transcript into a single prompt string, trimmed to the
/// on-device character budget (oldest turns dropped first).
private func buildPrompt() -> String {
var lines: [String] = []
for turn in turns {
let label = turn.role == "assistant" ? "Assistant" : "User"
lines.append("\(label): \(turn.text)")
}
var prompt = lines.joined(separator: "\n\n")
if prompt.count > promptCharBudget {
prompt = String(prompt.suffix(promptCharBudget))
}
return prompt
}

#if canImport(FoundationModels)
// MARK: - Generation

@available(iOS 26.0, *)
private func generate(requestBody: Data) async throws -> OpenAIResponse {
guard SystemLanguageModel.default.isAvailable else {
throw ResponsesError.http(status: 503, body: AppleOnDeviceAvailability.current.statusMessage)
}

let (schema, schemaName) = try ingest(requestBody)
let prompt = buildPrompt()
guard !prompt.isEmpty else { throw ResponsesError.emptyOutput }

let instructions = systemText.isEmpty
? "You are a concise, supportive personal health coach."
: systemText
let session = LanguageModelSession(instructions: instructions)

let text: String
if let schema, let generated = try? await respondGuided(session: session, prompt: prompt, schema: schema, name: schemaName) {
text = generated
} else if let schema {
// Guided generation unavailable for this schema — ask for raw JSON and
// let the orchestrator's JSONRepair recover it.
let directive = "\n\nRespond with a single JSON object only — no prose, no code fences."
text = try await respondText(session: session, prompt: prompt + jsonHint(schema) + directive)
} else {
text = try await respondText(session: session, prompt: prompt)
}

let responseId = UUID().uuidString
storedAssistant[responseId] = text
return OpenAIResponse(id: responseId, outputItems: [.message(text: text)])
}

@available(iOS 26.0, *)
private func respondText(session: LanguageModelSession, prompt: String) async throws -> String {
do {
let response = try await session.respond(to: prompt)
return response.content
} catch {
throw ResponsesError.transport(error)
}
}

/// Constrained decoding against a runtime-built schema. Returns the generated
/// content as a JSON string the existing decoders understand. Returns `nil`
/// (caller falls back) if the schema can't be translated.
@available(iOS 26.0, *)
private func respondGuided(session: LanguageModelSession, prompt: String, schema: [String: Any], name: String) async throws -> String? {
guard let dynamic = dynamicSchema(from: schema, name: name) else { return nil }
let generationSchema = try GenerationSchema(root: dynamic, dependencies: [])
let response = try await session.respond(to: prompt, schema: generationSchema)
return response.content.jsonString
}

/// Recursively translates the strict JSON Schema the orchestrator emits into
/// a FoundationModels `DynamicGenerationSchema`. Supports the subset the coach
/// uses (objects, arrays, strings + enums, numbers, booleans).
@available(iOS 26.0, *)
private func dynamicSchema(from schema: [String: Any], name: String) -> DynamicGenerationSchema? {
let type = schema["type"] as? String
switch type {
case "object":
let props = schema["properties"] as? [String: Any] ?? [:]
let required = Set(schema["required"] as? [String] ?? [])
var properties: [DynamicGenerationSchema.Property] = []
for (key, raw) in props {
guard let sub = raw as? [String: Any],
let child = dynamicSchema(from: sub, name: "\(name)_\(key)") else { continue }
properties.append(DynamicGenerationSchema.Property(
name: key,
description: sub["description"] as? String,
schema: child,
isOptional: !required.contains(key)
))
}
return DynamicGenerationSchema(name: name, description: schema["description"] as? String, properties: properties)
case "array":
guard let items = schema["items"] as? [String: Any],
let element = dynamicSchema(from: items, name: "\(name)_item") else { return nil }
return DynamicGenerationSchema(arrayOf: element)
case "string":
if let choices = schema["enum"] as? [String], !choices.isEmpty {
return DynamicGenerationSchema(name: name, anyOf: choices)
}
return DynamicGenerationSchema(type: String.self)
case "integer":
return DynamicGenerationSchema(type: Int.self)
case "number":
return DynamicGenerationSchema(type: Double.self)
case "boolean":
return DynamicGenerationSchema(type: Bool.self)
default:
// Union types like ["string", "null"] collapse to the first concrete
// type; anything unrecognized becomes a free string.
if let types = schema["type"] as? [String],
let concrete = types.first(where: { $0 != "null" }) {
return dynamicSchema(from: schema.merging(["type": concrete]) { _, new in new }, name: name)
}
return DynamicGenerationSchema(type: String.self)
}
}
#endif

/// A compact textual hint describing the desired JSON shape, used only on the
/// non-guided fallback path.
private func jsonHint(_ schema: [String: Any]) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: schema, options: [.sortedKeys]),
let str = String(data: data, encoding: .utf8) else { return "" }
return "\n\nMatch this JSON schema exactly:\n\(str)"
}
}
60 changes: 60 additions & 0 deletions PulseLoop/Coach/Apple/AppleModelAvailability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
#if canImport(FoundationModels)
import FoundationModels
#endif

/// Friendly wrapper over Apple's on-device `SystemLanguageModel` availability so
/// the rest of the app can gate the `appleOnDevice` provider without importing
/// FoundationModels everywhere — and so the project still compiles on an SDK or
/// device where the framework is absent.
enum AppleOnDeviceAvailability: Equatable {
case available
case deviceNotEligible
case appleIntelligenceNotEnabled
case modelNotReady
/// Built against an SDK / running on an OS without FoundationModels.
case frameworkUnavailable

/// Current availability of the default system language model.
static var current: AppleOnDeviceAvailability {
#if canImport(FoundationModels)
if #available(iOS 26.0, *) {
switch SystemLanguageModel.default.availability {
case .available:
return .available
case .unavailable(let reason):
switch reason {
case .deviceNotEligible: return .deviceNotEligible
case .appleIntelligenceNotEnabled: return .appleIntelligenceNotEnabled
case .modelNotReady: return .modelNotReady
@unknown default: return .modelNotReady
}
@unknown default:
return .modelNotReady
}
} else {
return .frameworkUnavailable
}
#else
return .frameworkUnavailable
#endif
}

var isAvailable: Bool { self == .available }

/// One-line, user-facing status for the Settings UI.
var statusMessage: String {
switch self {
case .available:
return "Ready · runs entirely on your iPhone"
case .deviceNotEligible:
return "Not supported on this device — needs an Apple Intelligence-capable iPhone."
case .appleIntelligenceNotEnabled:
return "Turn on Apple Intelligence in Settings to enable on-device coaching."
case .modelNotReady:
return "On-device model is preparing — try again shortly."
case .frameworkUnavailable:
return "On-device AI requires iOS 26 or later."
}
}
}
66 changes: 66 additions & 0 deletions PulseLoop/Coach/Config/CoachClientResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

/// Single source of truth for "which `ResponsesClient` runs, given the user's
/// settings + stored keys." Shared by the chat view-model, the summary service,
/// and the notification service so provider logic lives in exactly one place.
///
/// The returned `key` is a readiness sentinel: non-`nil` means the provider can
/// run (used to build `CoachFeatureFlags.hasAPIKey`). For cloud providers it's
/// the actual key; for on-device it's a `"on-device"` placeholder.
@MainActor
enum CoachClientResolver {
static func resolve(
settings: CoachSettings,
openAIKeyStore: APIKeyStore,
geminiKeyStore: APIKeyStore,
openRouterKeyStore: APIKeyStore,
openAIClientFactory: (String) -> ResponsesClient = { OpenAIResponsesClient(apiKey: $0) }

Check warning on line 17 in PulseLoop/Coach/Config/CoachClientResolver.swift

View workflow job for this annotation

GitHub Actions / Build & Test

call to main actor-isolated initializer 'init(apiKey:session:endpoint:)' in a synchronous nonisolated context
) -> (key: String?, client: ResponsesClient) {
switch settings.providerMode {
case .appleOnDevice:
// On-device only — no cloud backup. When the local model is usable,
// run it; otherwise hand back the client (it throws a clear error
// that surfaces in chat) and signal "not ready" so generators degrade
// to scripted.
let onDevice = AppleFoundationModelsClient()
let available = AppleOnDeviceAvailability.current.isAvailable
return (available ? "on-device" : nil, onDevice)
default:
return directClient(
settings.providerMode, settings: settings,
openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore,
openRouterKeyStore: openRouterKeyStore, openAIClientFactory: openAIClientFactory
)
}
}

/// Builds a client for a concrete (non-on-device) provider, mirroring the
/// prior per-call-site logic. Returns a client even when the key is absent
/// (`key == nil`); the feature-flags gate prevents an empty-key call.
private static func directClient(
_ mode: CoachProviderMode,
settings: CoachSettings,
openAIKeyStore: APIKeyStore,
geminiKeyStore: APIKeyStore,
openRouterKeyStore: APIKeyStore,
openAIClientFactory: (String) -> ResponsesClient
) -> (key: String?, client: ResponsesClient) {
switch mode {
case .userGeminiKey:
let key = (try? geminiKeyStore.readKey()) ?? nil
return (key, GeminiClient(apiKey: key ?? ""))
case .userOpenRouterKey:
let key = (try? openRouterKeyStore.readKey()) ?? nil
return (key, OpenRouterClient(
apiKey: key ?? "",
model: settings.openRouterModel,
privacyRouting: settings.orEnablePrivacyRouting,
providerSort: settings.orProviderSort))
default:
// userOpenAIKey / offlineStub / backendProxy (and appleOnDevice never
// reaches here) all use the OpenAI key + factory.
let key = (try? openAIKeyStore.readKey()) ?? nil
return (key, openAIClientFactory(key ?? ""))
}
}
}
Loading
Loading