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
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation

/// Logical grouping for an LLM call site, used to organize the per-call-site
/// overrides view introduced in PRs 22-24.
///
/// Mirrors the structure documented in the unify-llm-callsites plan: each
/// call-site ID belongs to exactly one domain so the UI can render them in
/// a stable, user-friendly order.
public enum CallSiteDomain: String, CaseIterable, Identifiable, Hashable {
case agentLoop
case memory
case workspace
case ui
case notifications
case voice
case utility

public var id: String { rawValue }

/// User-facing label for this domain. Shown as a section header in the
/// per-call-site override picker.
public var displayName: String {
switch self {
case .agentLoop: return "Agent loop"
case .memory: return "Memory"
case .workspace: return "Workspace"
case .ui: return "UI"
case .notifications: return "Notifications"
case .voice: return "Voice"
case .utility: return "Utility"
}
}

/// Stable display order for sections in the override picker. Lower values
/// appear first.
public var sortOrder: Int {
switch self {
case .agentLoop: return 0
case .memory: return 1
case .workspace: return 2
case .ui: return 3
case .notifications: return 4
case .voice: return 5
case .utility: return 6
}
}
}

/// A user-editable override entry for a single LLM call site.
///
/// Mirrors the wire shape of `llm.callSites.<id>` in the assistant config:
/// any combination of `provider`, `model`, and `profile` may be set; an
/// entry where all three are `nil` represents "follows the default".
public struct CallSiteOverride: Identifiable, Equatable, Hashable {
/// Stable call-site identifier matching the backend `LLMCallSiteEnum`
/// (e.g. `"memoryRetrieval"`).
public let id: String

/// User-facing label shown in the override picker
/// (e.g. `"Memory · Retrieval"`).
public let displayName: String

/// Logical grouping for sectioning in the picker.
public let domain: CallSiteDomain

/// Provider override; `nil` means "follows the default".
public var provider: String?

/// Model override; `nil` means "follows the default" (or the profile,
/// when one is selected).
public var model: String?

/// Profile override referencing a key in `llm.profiles`; `nil` means
/// "no profile selected".
public var profile: String?

public init(
id: String,
displayName: String,
domain: CallSiteDomain,
provider: String? = nil,
model: String? = nil,
profile: String? = nil
) {
self.id = id
self.displayName = displayName
self.domain = domain
self.provider = provider
self.model = model
self.profile = profile
}

/// True when this entry has at least one explicit override
/// (`provider`, `model`, or `profile`).
public var hasOverride: Bool {
provider != nil || model != nil || profile != nil
}
}

/// Static catalog of every call site the assistant exposes.
///
/// Mirrors the backend `LLMCallSiteEnum` in
/// `assistant/src/config/schemas/llm.ts`. When the backend enum changes,
/// update this catalog in lockstep so the macOS UI can render every site
/// without depending on a runtime fetch.
public enum CallSiteCatalog {
/// All known call sites, paired with their display name and domain.
/// Order matches the backend enum so the UI is deterministic.
public static let all: [CallSiteOverride] = [
// Agent loop
CallSiteOverride(id: "mainAgent", displayName: "Main agent", domain: .agentLoop),
CallSiteOverride(id: "subagentSpawn", displayName: "Subagent spawn", domain: .agentLoop),
CallSiteOverride(id: "heartbeatAgent", displayName: "Heartbeat agent", domain: .agentLoop),
CallSiteOverride(id: "filingAgent", displayName: "Filing agent", domain: .agentLoop),
CallSiteOverride(id: "analyzeConversation", displayName: "Analyze conversation", domain: .agentLoop),
CallSiteOverride(id: "callAgent", displayName: "Call agent", domain: .agentLoop),
// Memory
CallSiteOverride(id: "memoryExtraction", displayName: "Memory · Extraction", domain: .memory),
CallSiteOverride(id: "memoryConsolidation", displayName: "Memory · Consolidation", domain: .memory),
CallSiteOverride(id: "memoryRetrieval", displayName: "Memory · Retrieval", domain: .memory),
CallSiteOverride(id: "narrativeRefinement", displayName: "Narrative refinement", domain: .memory),
CallSiteOverride(id: "patternScan", displayName: "Pattern scan", domain: .memory),
CallSiteOverride(id: "conversationSummarization", displayName: "Conversation summarization", domain: .memory),
CallSiteOverride(id: "conversationStarters", displayName: "Conversation starters", domain: .memory),
// Workspace
CallSiteOverride(id: "conversationTitle", displayName: "Conversation title", domain: .workspace),
CallSiteOverride(id: "commitMessage", displayName: "Commit message generator", domain: .workspace),
// UI
CallSiteOverride(id: "identityIntro", displayName: "Identity intro", domain: .ui),
CallSiteOverride(id: "emptyStateGreeting", displayName: "Empty-state greeting", domain: .ui),
// Notifications
CallSiteOverride(id: "notificationDecision", displayName: "Notification decision", domain: .notifications),
CallSiteOverride(id: "preferenceExtraction", displayName: "Preference extraction", domain: .notifications),
// Voice
CallSiteOverride(id: "guardianQuestionCopy", displayName: "Guardian question copy", domain: .voice),
CallSiteOverride(id: "watchCommentary", displayName: "Watch commentary", domain: .voice),
CallSiteOverride(id: "watchSummary", displayName: "Watch summary", domain: .voice),
// Utility
CallSiteOverride(id: "interactionClassifier", displayName: "Interaction classifier", domain: .utility),
CallSiteOverride(id: "styleAnalyzer", displayName: "Style analyzer", domain: .utility),
CallSiteOverride(id: "inviteInstructionGenerator", displayName: "Invite instruction generator", domain: .utility),
CallSiteOverride(id: "skillCategoryInference", displayName: "Skill category inference", domain: .utility),
]

/// Lookup table from call-site ID to its catalog entry. Constructed
/// once at first access for O(1) lookup during config sync.
public static let byId: [String: CallSiteOverride] = {
Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
}()

/// Set of valid call-site IDs, used to validate / filter raw config
/// payloads coming back from the daemon.
public static let validIds: Set<String> = Set(all.map { $0.id })
}
207 changes: 207 additions & 0 deletions clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ public final class SettingsStore: ObservableObject {
/// Full provider catalog from daemon. Seeded with inline defaults for pre-fetch rendering.
@Published var providerCatalog: [ProviderCatalogEntry] = []

// MARK: - Per-Call-Site LLM Overrides

/// Catalog of every LLM call site, merged with whatever overrides the
/// user has configured under `llm.callSites.<id>` in the workspace
/// config. Order matches `CallSiteCatalog.all` so the UI renders a
/// stable list grouped by `CallSiteDomain`.
///
/// Seeded from the static catalog so the picker has every row available
/// before the first daemon fetch completes. Replaced by
/// `loadCallSiteOverrides(config:)` once the daemon reports the
/// authoritative config.
@Published var callSiteOverrides: [CallSiteOverride] = CallSiteCatalog.all

static let availableImageGenModels: [String] = [
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image-preview",
Expand Down Expand Up @@ -3023,6 +3036,199 @@ public final class SettingsStore: ObservableObject {
return task
}

// MARK: - Per-Call-Site Override Read / Write

/// Number of entries in `callSiteOverrides` that have at least one
/// explicit override (`provider`, `model`, or `profile`). Useful for
/// rendering a badge on the overrides settings entry.
var overridesCount: Int {
callSiteOverrides.lazy.filter { $0.hasOverride }.count
}

/// Reads `llm.callSites.<id>` from the workspace config dictionary,
/// merges every entry against `CallSiteCatalog.all`, and replaces
/// `callSiteOverrides`. Catalog entries missing from the config map to
/// "no override" (all fields `nil`), preserving display order.
///
/// Unknown call-site IDs in the config (e.g. ones added on a newer
/// daemon) are silently ignored — the catalog is the source of truth
/// for what the UI can render.
func loadCallSiteOverrides(config: [String: Any]) {
let llm = config["llm"] as? [String: Any]
let callSitesRaw = (llm?["callSites"] as? [String: Any]) ?? [:]
var byId: [String: (provider: String?, model: String?, profile: String?)] = [:]
for (id, raw) in callSitesRaw {
guard CallSiteCatalog.validIds.contains(id),
let entry = raw as? [String: Any] else { continue }
let provider = (entry["provider"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let model = (entry["model"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let profile = (entry["profile"] as? String).flatMap { $0.isEmpty ? nil : $0 }
byId[id] = (provider: provider, model: model, profile: profile)
}
self.callSiteOverrides = CallSiteCatalog.all.map { entry in
var merged = entry
if let raw = byId[entry.id] {
merged.provider = raw.provider
merged.model = raw.model
merged.profile = raw.profile
} else {
merged.provider = nil
merged.model = nil
merged.profile = nil
}
return merged
}
}

/// Persists an override for a single call site at
/// `llm.callSites.<id>.{provider,model,profile}`. Nil arguments are
/// omitted from the patch payload — passing `provider: nil` does
/// **not** clear an existing provider override; use
/// `clearCallSiteOverride(_:)` for that.
///
/// The local `callSiteOverrides` cache is updated optimistically so
/// SwiftUI views reflect the change immediately.
@discardableResult
func setCallSiteOverride(
_ id: String,
provider: String? = nil,
model: String? = nil,
profile: String? = nil
) -> Task<Bool, Never> {
guard CallSiteCatalog.validIds.contains(id) else {
log.error("setCallSiteOverride: unknown call-site id \(id, privacy: .public)")
return Task { false }
}
if let index = callSiteOverrides.firstIndex(where: { $0.id == id }) {
if let provider { callSiteOverrides[index].provider = provider }
if let model { callSiteOverrides[index].model = model }
if let profile { callSiteOverrides[index].profile = profile }
}
var entry: [String: Any] = [:]
if let provider { entry["provider"] = provider }
if let model { entry["model"] = model }
if let profile { entry["profile"] = profile }
let payload: [String: Any] = ["llm": ["callSites": [id: entry]]]
let task = Task {
let success = await settingsClient.patchConfig(payload)
if !success {
log.error("Failed to patch config for llm.callSites.\(id, privacy: .public)")
}
return success
}
return task
}

/// Clears the override for a single call site by writing `null` to
/// `llm.callSites.<id>` itself, which the Zod fragment treats as
/// "absent" and the resolver falls back to `llm.default`.
///
/// We null the entire entry rather than just `provider`/`model`/`profile`
/// because `LLMCallSiteConfig` supports additional leaves
/// (`maxTokens`, `effort`, `speed`, `temperature`, `thinking`,
/// `contextWindow`) that may have been set via manual config edits or
/// other clients. Clearing only the three Settings-UI-managed fields
/// would leave hidden overrides applied — the user couldn't truly
/// reset the call site.
@discardableResult
func clearCallSiteOverride(_ id: String) -> Task<Bool, Never> {
guard CallSiteCatalog.validIds.contains(id) else {
log.error("clearCallSiteOverride: unknown call-site id \(id, privacy: .public)")
return Task { false }
}
if let index = callSiteOverrides.firstIndex(where: { $0.id == id }) {
callSiteOverrides[index].provider = nil
callSiteOverrides[index].model = nil
callSiteOverrides[index].profile = nil
}
let payload: [String: Any] = [
"llm": ["callSites": [id: NSNull()]],
]
let task = Task {
let success = await settingsClient.patchConfig(payload)
if !success {
log.error("Failed to patch config to clear llm.callSites.\(id, privacy: .public)")
}
return success
}
return task
}

/// Batch update of every entry in `overrides`. Each entry's
/// `provider`/`model`/`profile` is written verbatim; `nil` fields are
/// emitted as JSON null so the daemon clears them via the same
/// deep-merge mechanism as `clearCallSiteOverride(_:)`.
///
/// Useful for "reset all overrides" or "apply preset" actions that
/// touch many call sites in a single round trip. The local
/// `callSiteOverrides` cache is replaced so SwiftUI views reflect
/// the new state immediately.
@discardableResult
func setCallSiteOverrides(_ overrides: [CallSiteOverride]) -> Task<Bool, Never> {
let validOverrides = overrides.filter { CallSiteCatalog.validIds.contains($0.id) }
// Preserve catalog order in the local cache so SwiftUI lists stay stable.
// Use `uniquingKeysWith:` (last-write-wins) instead of
// `uniqueKeysWithValues:` to tolerate duplicate IDs from external
// input — the latter traps at runtime on collisions.
let overrideById = Dictionary(
validOverrides.map { ($0.id, $0) },
uniquingKeysWith: { _, new in new }
)
callSiteOverrides = CallSiteCatalog.all.map { entry in
var merged = entry
if let provided = overrideById[entry.id] {
merged.provider = provided.provider
merged.model = provided.model
merged.profile = provided.profile
} else {
merged.provider = nil
merged.model = nil
merged.profile = nil
}
return merged
}
var callSitesPayload: [String: Any] = [:]
for entry in validOverrides {
// Emit explicit JSON null for absent fields so the daemon's
Comment thread
siddseethepalli marked this conversation as resolved.
// deep-merge clears them rather than leaving stale values in
// place. Build the dict with NSNull placeholders, then
// overwrite with the real string values when present — this
// avoids the Optional-to-Any nil-flattening trap.
var rawEntry: [String: Any] = [
"provider": NSNull(),
"model": NSNull(),
"profile": NSNull(),
]
if let provider = entry.provider { rawEntry["provider"] = provider }
if let model = entry.model { rawEntry["model"] = model }
if let profile = entry.profile { rawEntry["profile"] = profile }
callSitesPayload[entry.id] = rawEntry
}
Comment thread
siddseethepalli marked this conversation as resolved.
// Align remote with local: any catalog entry NOT in `validOverrides`
// is locally cleared above (provider/model/profile -> nil), so the
// PATCH must explicitly clear those entries on the daemon as well.
// Without this, omitted entries would appear cleared in the UI but
// the daemon would retain their previous values, and the stale
// values would "reappear" on the next config sync.
//
// Null the entire `callSites.<id>` entry (rather than just the three
// Settings-managed fields) so any other leaves an entry might have
// — `maxTokens`, `effort`, `speed`, `thinking`, `contextWindow` —
// are cleared too. Same rationale as `clearCallSiteOverride`.
for entry in CallSiteCatalog.all where callSitesPayload[entry.id] == nil {
callSitesPayload[entry.id] = NSNull()
}
let payload: [String: Any] = ["llm": ["callSites": callSitesPayload]]
let task = Task {
let success = await settingsClient.patchConfig(payload)
if !success {
log.error("Failed to patch config for batch llm.callSites update (\(validOverrides.count, privacy: .public) entries)")
}
return success
}
return task
}

/// Persists the selected TTS provider to the daemon config so synthesis
/// routes through the correct backend. The canonical config path is
/// `services.tts.provider`.
Expand Down Expand Up @@ -3812,6 +4018,7 @@ public final class SettingsStore: ObservableObject {
Self.applyHostBrowserCdpInspectConfig(config, into: self)

loadServiceModes(config: config)
loadCallSiteOverrides(config: config)

// Persist enabledSince when it was defaulted so subsequent loads
// produce a deterministic timestamp.
Expand Down
Loading