Skip to content
Open
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
23 changes: 23 additions & 0 deletions PulseLoop/Coach/Config/CoachClientResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
geminiKeyStore: APIKeyStore,
openRouterKeyStore: APIKeyStore,
minimaxKeyStore: APIKeyStore,
// Defaulted so the four existing call sites don't each have to learn about a provider
// whose key is optional anyway.
localKeyStore: APIKeyStore = LocalLLMKeychainStore(),

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

View workflow job for this annotation

GitHub Actions / Build & Test

call to main actor-isolated initializer 'init(service:account:)' in a synchronous nonisolated context
openAIClientFactory: (String) -> ResponsesClient = { OpenAIResponsesClient(apiKey: $0) }

Check warning on line 21 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:
Expand All @@ -31,6 +34,7 @@
settings.providerMode, settings: settings,
openAIKeyStore: openAIKeyStore, geminiKeyStore: geminiKeyStore,
openRouterKeyStore: openRouterKeyStore, minimaxKeyStore: minimaxKeyStore,
localKeyStore: localKeyStore,
openAIClientFactory: openAIClientFactory
)
}
Expand All @@ -46,6 +50,7 @@
geminiKeyStore: APIKeyStore,
openRouterKeyStore: APIKeyStore,
minimaxKeyStore: APIKeyStore,
localKeyStore: APIKeyStore,
openAIClientFactory: (String) -> ResponsesClient
) -> (key: String?, client: ResponsesClient) {
switch mode {
Expand All @@ -62,6 +67,24 @@
case .userMiniMaxKey:
let key = (try? minimaxKeyStore.readKey()) ?? nil
return (key, MiniMaxClient(apiKey: key ?? "", model: settings.minimaxModel))
case .localOpenAICompat:
// Readiness is a base URL that would actually work — `validate`, not "non-empty".
// Settings persists the field as the user types, so a non-empty check would flip the
// coach to "Active" on the first character and every turn would then fail inside
// `send()` with the same URL error the Settings field is already showing inline.
// The key may legitimately be absent and is passed through as nil so the client omits
// the Authorization header entirely.
let baseURL = settings.resolvedLocalBaseURL
let key = (try? localKeyStore.readKey()) ?? nil
let ready = LocalEndpoint.validate(baseURL) == nil ? baseURL : nil
return (ready, LocalOpenAICompatClient(
baseURL: baseURL,
model: settings.resolvedLocalModel,
apiKey: key,
toolCallingEnabled: settings.localToolCalling,
structuredOutput: settings.localStructuredOutput,
maxOutputTokens: settings.localMaxTokens > 0 ? settings.localMaxTokens : nil,
readTimeoutSeconds: settings.localTimeoutSeconds))
default:
// userOpenAIKey / offlineStub / backendProxy (and appleOnDevice never
// reaches here) all use the OpenAI key + factory.
Expand Down
12 changes: 12 additions & 0 deletions PulseLoop/Coach/Config/CoachFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ struct CoachFeatureFlags {
return AppleOnDeviceAvailability.current.isAvailable
case .userOpenAIKey, .userGeminiKey, .userOpenRouterKey, .userMiniMaxKey:
return hasAPIKey
case .localOpenAICompat:
// `hasAPIKey` carries the local provider's readiness sentinel, which is a *usable base
// URL* rather than a key — the key is optional on every engine in scope.
return hasAPIKey
case .backendProxy:
return false // not implemented in v1
}
Expand Down Expand Up @@ -60,6 +64,10 @@ struct CoachFeatureFlags {
case .offlineStub: return "offline-stub"
case .userOpenRouterKey: return settings.openRouterModel
case .userMiniMaxKey: return settings.minimaxModel
// Blank is legitimate: llama.cpp ignores the field unless started with --alias. Label it
// rather than leave the usage row empty.
case .localOpenAICompat:
return settings.resolvedLocalModel.isEmpty ? "local-model" : settings.resolvedLocalModel
case .userOpenAIKey, .userGeminiKey, .backendProxy: return settings.model
}
}
Expand All @@ -80,6 +88,10 @@ struct CoachFeatureFlags {
return hasAPIKey ? "Ready · \(settings.openRouterModel)" : "Add an OpenRouter key to enable."
case .userMiniMaxKey:
return hasAPIKey ? "Ready · \(settings.minimaxModel)" : "Add a MiniMax key to enable."
case .localOpenAICompat:
guard hasAPIKey else { return "Add your server's address to enable." }
let model = settings.resolvedLocalModel
return model.isEmpty ? "Ready · self-hosted" : "Ready · \(model)"
case .backendProxy:
return "Backend proxy not available yet."
}
Expand Down
70 changes: 70 additions & 0 deletions PulseLoop/Coach/Config/CoachSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ enum CoachProviderMode: String, Codable, CaseIterable, Identifiable {
case userGeminiKey
case userOpenRouterKey
case userMiniMaxKey
/// Any OpenAI-Chat-Completions-compatible server the user runs themselves: Ollama, llama.cpp,
/// vLLM, SGLang, LM Studio. Declared after the keyed providers so inserting it doesn't disturb
/// anyone's stored `rawValue`.
case localOpenAICompat
case backendProxy

var id: String { rawValue }
Expand All @@ -22,11 +26,43 @@ enum CoachProviderMode: String, Codable, CaseIterable, Identifiable {
case .userGeminiKey: return "Gemini"
case .userOpenRouterKey: return "OpenRouter"
case .userMiniMaxKey: return "MiniMax"
case .localOpenAICompat: return "Local / self-hosted"
case .backendProxy: return "Backend proxy"
}
}
}

/// How hard to constrain a local model's output shape — see `docs/local-llm-coach.md` §5.
///
/// `.off` is the default because it's the only mode implemented by every backend: the coach's
/// shape is carried by `CoachResponseSchema.promptInstruction` in the system message, with the
/// orchestrator's JSON-repair loop as the backstop. The other two are opt-in because the support
/// matrix is genuinely uneven — LM Studio implements `json_schema` but not `json_object`, and some
/// llama.cpp builds error when `json_schema` collides with a server-side `grammar`.
enum LocalStructuredOutput: String, Codable, CaseIterable, Identifiable, Sendable {
case off
case jsonObject
case jsonSchema

var id: String { rawValue }

var label: String {
switch self {
case .off: return "Prompt only"
case .jsonObject: return "JSON mode"
case .jsonSchema: return "Strict schema"
}
}

var blurb: String {
switch self {
case .off: return "Works everywhere (default)"
case .jsonObject: return "response_format: json_object — not on LM Studio"
case .jsonSchema: return "response_format: json_schema — best when supported"
}
}
}

/// Preset MiniMax model choices surfaced in Settings. MiniMax's catalog is small
/// and fixed (unlike OpenRouter), so these are exact API model names rather than a
/// free-text field. The stored `CoachSettings.model` stays a string, so a new
Expand Down Expand Up @@ -189,6 +225,34 @@ struct CoachSettings: Codable, Equatable {
/// can ground practical advice (outdoor vs indoor, hydration, rain). City-level
/// only — never the precise location. Off by default.
var enableEnvironmentContext: Bool = false
/// Local-only: base URL of the self-hosted server, as typed (`http://192.168.1.50:11434`).
/// A URL that `LocalEndpoint.validate` accepts — not the key — is what gates readiness.
var localBaseURL: String = ""
/// Local-only: model name the server expects. Free-form; `/v1/models` populates the picker.
var localModel: String = ""
/// Local-only: send `tools`. Off for a server started without tool-call support (vLLM without
/// `--enable-auto-tool-choice` returns HTTP 400) or a model that can't call them.
var localToolCalling: Bool = true
/// Local-only: how hard to constrain the output shape. Default off — the only mode every
/// backend supports.
var localStructuredOutput: LocalStructuredOutput = .off
/// Local-only: `max_tokens`; 0 = omit and let the server decide.
var localMaxTokens: Int = 0
/// Local-only: read timeout in seconds. Long, because CPU inference is slow.
var localTimeoutSeconds: Int = LocalOpenAICompatClient.defaultReadTimeoutSeconds

/// The local base URL with surrounding whitespace gone; blank when unconfigured. There's no
/// default to fall back to — every engine listens on a different port.
var resolvedLocalBaseURL: String {
localBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// The local model name. Blank is sent as-is rather than substituted: llama.cpp ignores the
/// field entirely, so an empty value is legitimate there, and inventing a slug would turn a
/// working setup into a 404 on the servers that do read it.
var resolvedLocalModel: String {
localModel.trimmingCharacters(in: .whitespacesAndNewlines)
}

/// The OpenRouter model slug to use. Free-form (the user may type any slug);
/// falls back to the default only when the stored `model` is blank.
Expand Down Expand Up @@ -231,6 +295,12 @@ struct CoachSettings: Codable, Equatable {
eveningHour = try c.decodeIfPresent(Int.self, forKey: .eveningHour) ?? d.eveningHour
proactiveAlertsEnabled = try c.decodeIfPresent(Bool.self, forKey: .proactiveAlertsEnabled) ?? d.proactiveAlertsEnabled
enableEnvironmentContext = try c.decodeIfPresent(Bool.self, forKey: .enableEnvironmentContext) ?? d.enableEnvironmentContext
localBaseURL = try c.decodeIfPresent(String.self, forKey: .localBaseURL) ?? d.localBaseURL
localModel = try c.decodeIfPresent(String.self, forKey: .localModel) ?? d.localModel
localToolCalling = try c.decodeIfPresent(Bool.self, forKey: .localToolCalling) ?? d.localToolCalling
localStructuredOutput = try c.decodeIfPresent(LocalStructuredOutput.self, forKey: .localStructuredOutput) ?? d.localStructuredOutput
localMaxTokens = try c.decodeIfPresent(Int.self, forKey: .localMaxTokens) ?? d.localMaxTokens
localTimeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .localTimeoutSeconds) ?? d.localTimeoutSeconds
}
}

Expand Down
63 changes: 62 additions & 1 deletion PulseLoop/Coach/Config/CoachSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct CoachSettingsSection: View {
private let geminiKeyStore = GeminiKeychainStore()
private let openRouterKeyStore = OpenRouterKeychainStore()
private let minimaxKeyStore = MiniMaxKeychainStore()
private let localKeyStore = LocalLLMKeychainStore()

/// Picker tag that selects the free-text "Custom" OpenRouter model entry.
private let customModelTag = "__custom__"
Expand All @@ -41,6 +42,8 @@ struct CoachSettingsSection: View {
return selected == customModelTag ? "Custom…" : (OpenRouterModel(rawValue: selected)?.label ?? selected)
case .userMiniMaxKey:
return MiniMaxModel(rawValue: selected)?.label ?? selected
case .localOpenAICompat:
return store.settings.resolvedLocalModel.isEmpty ? "Not set" : store.settings.resolvedLocalModel
default:
return CoachModel(rawValue: selected)?.label ?? selected
}
Expand Down Expand Up @@ -70,12 +73,22 @@ struct CoachSettingsSection: View {
@State private var showMiniMaxKey: Bool = false
@State private var minimaxKeyError: String?

// Local / self-hosted state. The key is optional here, so it has the same shape as the others
// but never gates anything.
@State private var localKeyDraft: String = ""
@State private var hasLocalKey: Bool = false
@State private var showLocalKey: Bool = false
@State private var localKeyError: String?

private var flags: CoachFeatureFlags {
let hasKey: Bool
switch store.settings.providerMode {
case .userGeminiKey: hasKey = hasGeminiKey
case .userOpenRouterKey: hasKey = hasOpenRouterKey
case .userMiniMaxKey: hasKey = hasMiniMaxKey
// The local provider's key is optional; a usable base URL is what makes it ready. Mirrors
// the readiness sentinel in `CoachClientResolver`, which is what actually gates a turn.
case .localOpenAICompat: hasKey = LocalEndpoint.validate(store.settings.localBaseURL) == nil
default: hasKey = hasSavedKey
}
return CoachFeatureFlags(settings: store.settings, hasAPIKey: hasKey)
Expand Down Expand Up @@ -141,6 +154,10 @@ struct CoachSettingsSection: View {
// model picker.
if store.settings.providerMode == .appleOnDevice {
appleOnDeviceCard
} else if store.settings.providerMode == .localOpenAICompat {
// The model list comes from the user's own server, so it belongs with the rest
// of the server setup below rather than in a fixed picker here.
EmptyView()
} else {
FormMenuRow(title: "Model", value: currentModelLabel) {
Picker("Model", selection: modelPickerBinding) {
Expand Down Expand Up @@ -220,6 +237,23 @@ struct CoachSettingsSection: View {
)
}

if store.settings.providerMode == .localOpenAICompat {
LocalServerSettingsSection()
// The key field stays here so it looks and behaves exactly like every other
// provider's — it is simply optional for this one.
apiKeyField(
placeholder: "Optional API key",
hint: "Only needed if you started your server with --api-key. Ollama ignores it "
+ "entirely. Stored in your device Keychain.",
draft: $localKeyDraft,
showRaw: $showLocalKey,
hasSaved: hasLocalKey,
error: localKeyError,
onSave: saveLocalKey,
onRemove: removeLocalKey
)
}

// OpenRouter-only routing controls. OpenRouter exposes a unified
// reasoning-effort hint plus provider-level privacy and sort options
// the native OpenAI/Gemini clients don't, so they only appear here.
Expand Down Expand Up @@ -256,7 +290,11 @@ struct CoachSettingsSection: View {
// and the on-device model is tool-less — so the toggle is only offered
// for providers that can actually search.
if store.settings.providerMode != .appleOnDevice,
store.settings.providerMode != .userMiniMaxKey {
store.settings.providerMode != .userMiniMaxKey,
// No local engine hosts a web-search tool; `LocalOpenAICompatClient` drops the
// spec defensively, and offering the switch would only promise something the
// server can't do.
store.settings.providerMode != .localOpenAICompat {
FormToggleRow(title: "Web search", isOn: webSearchBinding)
}

Expand Down Expand Up @@ -394,6 +432,28 @@ struct CoachSettingsSection: View {

// MARK: - Custom OpenRouter model field

private func saveLocalKey() {
do {
try localKeyStore.saveKey(localKeyDraft)
hasLocalKey = true
localKeyDraft = ""
localKeyError = nil
} catch {
localKeyError = error.localizedDescription
}
}

private func removeLocalKey() {
do {
try localKeyStore.deleteKey()
hasLocalKey = false
localKeyDraft = ""
localKeyError = nil
} catch {
localKeyError = error.localizedDescription
}
}

private var customModelField: some View {
FormField {
VStack(alignment: .leading, spacing: 8) {
Expand Down Expand Up @@ -543,6 +603,7 @@ struct CoachSettingsSection: View {
hasGeminiKey = ((try? geminiKeyStore.readKey()) ?? nil) != nil
hasOpenRouterKey = ((try? openRouterKeyStore.readKey()) ?? nil) != nil
hasMiniMaxKey = ((try? minimaxKeyStore.readKey()) ?? nil) != nil
hasLocalKey = ((try? localKeyStore.readKey()) ?? nil) != nil
}

private func saveOpenAIKey() {
Expand Down
Loading
Loading