Files
OSGKeyboard/OSGKeyboardShared/Services/AppGroupStore.swift
Rocky 3c10d73d7f feat(ai): unify reply center and refresh keyboard AI features
- Merge invitation, task, blessing, clarification, and empathy actions
  into a single Reply flow, with three fixed, clearly labeled stance
  choices whenever user intent must not be guessed.
- Refine clipboard semantic routing with bilingual schedule,
  confirmation, and follow-up models, conservative language thresholds,
  and explicit-assignment guard for complaint-only text.
- Persist Apple account refresh state, harden session recovery, and
  surface durable account diagnostics across keyboard and app.
- Derive personal-style prompts through two-stage corpus evidence and
  apply real low-confidence ASR tendencies instead of neutral templates.
- Localize the new reply center, clipboard semantics, and personal-style
  surfaces in both English and Simplified Chinese.
2026-08-29 11:51:21 +08:00

645 lines
26 KiB
Swift

// AppGroupStore.swift
// OSGKeyboard · Shared
//
// Thin read/write facade over `AppGroupConfiguration` for the keyboard
// extension (no SwiftUI) and other non-ObservableObject call sites.
//
// `apiKey` is NOT stored in UserDefaults — see `Keychain.swift`.
import Foundation
/// Sendable facade over thread-safe UserDefaults, hence `@unchecked`; callers
/// must still serialize compound read-modify-write mutations. iOS requires the
/// App Group (except unsigned tests), while macOS may use `.standard`. API keys
/// are resolved from Keychain and never saved here.
public struct AppGroupStore: @unchecked Sendable {
public let defaults: UserDefaults
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
return
}
if let available = AppGroup.defaultsIfAvailable {
self.defaults = available
return
}
#if os(iOS)
// iOS app + keyboard extension MUST share the App Group suite; a
// silent `.standard` fallback would desync them. Keep this a hard
// failure so a provisioning mistake is impossible to miss.
//
// Exception: unsigned XCTest hosts (`CODE_SIGNING_ALLOWED=NO`) often
// lack the App Group container. `fatalError` here aborts the whole
// test process (SIGTRAP) and masks real assertion failures — use an
// isolated suite only while XCTest is loaded.
#if DEBUG
if NSClassFromString("XCTestCase") != nil {
let suiteName = "\(AppGroup.identifier).xctest-fallback"
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
return
}
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
#else
fatalError("App Group unavailable.")
#endif
#else
// macOS is a standalone menu-bar app with no keyboard extension to
// stay in sync with, so a missing App Group container is expected;
// fall back to the app's standard defaults.
self.defaults = .standard
#endif
}
private var configuration: AppGroupConfiguration {
AppGroupConfiguration.load(fromAvailable: defaults)
}
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
var config = baseline
transform(&config)
config.saveChanges(since: baseline, to: defaults)
}
// MARK: - Reads
public var providerId: String { configuration.providerId }
public var baseURL: String { configuration.baseURL }
public var apiKey: String { configuration.apiKey }
public var model: String { configuration.model }
public var asrProviderId: String { configuration.asrProviderId }
public var asrBaseURL: String { configuration.resolvedASRBaseURL }
public var asrApiKey: String { configuration.asrApiKey }
public var asrModel: String { configuration.resolvedASRModel }
public var modeId: String { configuration.modeId }
public var localeId: String { configuration.localeId }
public var engineMode: String { configuration.engineMode }
public var credentialSource: CredentialSource { configuration.credentialSource }
/// Non-secret host-authentication marker used to gate account-funded grants.
public var isManagedGatewayAccountSessionAvailable: Bool {
defaults.bool(
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
)
}
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
public var translationEnabled: Bool { configuration.translationEnabled }
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
public var multipleReplyVariantsEnabled: Bool { configuration.multipleReplyVariantsEnabled }
public var polishStyleCatalog: PolishStyleCatalog { configuration.polishStyleCatalog }
public var activePolishStyleId: String { configuration.activePolishStyleId }
public var activePolishStyle: PolishStylePack {
PolishStylePackCatalog.resolve(id: activePolishStyleId, userCatalog: polishStyleCatalog)
}
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
public var clipboardHistoryEnabled: Bool { configuration.clipboardHistoryEnabled }
public var clipboardCandidateBarEnabled: Bool { configuration.clipboardCandidateBarEnabled }
public var isPolishKeyMissing: Bool { configuration.isPolishKeyMissing }
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
public var isLocalEngine: Bool { configuration.isLocalEngine }
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
/// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
public var agentSkillLayout: AIAgentSkillLayout {
Self.decodeAgentSkillLayout(
from: defaults,
userCatalog: agentUserSkillCatalog,
officialCatalog: officialSkillCatalog,
uiLanguage: uiLanguage
)
}
public var agentUserSkillCatalog: AIUserSkillCatalog {
Self.decodeUserSkillCatalog(from: defaults)
}
/// Last-known-good host-fetched catalog. The extension only reads this snapshot.
public var officialSkillCatalog: OfficialSkillCatalog {
Self.decodeOfficialSkillCatalog(from: defaults)
}
public var resolvedAgentSkillCatalog: [AIClipboardSkill] {
AIClipboardSkillCatalog.all(
officialCatalog: officialSkillCatalog,
userCatalog: agentUserSkillCatalog,
uiLanguage: uiLanguage
)
}
// MARK: - Writes
public func setModeId(_ id: String) {
mutateConfiguration { $0.modeId = id }
}
public func setLocaleId(_ id: String) {
mutateConfiguration { $0.localeId = id }
AppGroupConfigDarwin.postConfigChanged()
}
public func setEngineMode(_ mode: String) {
mutateConfiguration { $0.engineMode = mode }
AppGroupConfigDarwin.postConfigChanged()
}
public func setCredentialSource(_ source: CredentialSource) {
mutateConfiguration { $0.credentialSource = source }
AppGroupConfigDarwin.postConfigChanged()
}
public func setManagedGatewayAccountSessionAvailable(_ available: Bool) {
defaults.set(
available,
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
)
AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
mutateConfiguration { $0.uiLanguage = language }
}
public func setTranslationEnabled(_ enabled: Bool) {
setTranslationTargetLocaleId(
enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId
)
}
public func setTranslationTargetLocaleId(_ id: String) {
mutateConfiguration { $0.translationTargetLocaleId = id }
AppGroupConfigDarwin.postConfigChanged()
}
public func setHandednessPreference(_ preference: HandednessPreference) {
mutateConfiguration { $0.handednessPreference = preference }
AppGroupConfigDarwin.postConfigChanged()
}
public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) {
mutateConfiguration { $0.keyboardHapticIntensity = intensity }
AppGroupConfigDarwin.postConfigChanged()
}
public func setPolishIntensity(_ intensity: PolishIntensity) {
mutateConfiguration { $0.polishIntensity = intensity }
AppGroupConfigDarwin.postConfigChanged()
}
public func setAIResponseLength(_ length: AIResponseLength) {
mutateConfiguration { $0.aiResponseLength = length }
AppGroupConfigDarwin.postConfigChanged()
}
public func setMultipleReplyVariantsEnabled(_ enabled: Bool) {
mutateConfiguration { $0.multipleReplyVariantsEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
// MARK: - Polish styles
public func setPolishStyleCatalog(_ catalog: PolishStyleCatalog) {
mutateConfiguration { $0.polishStyleCatalog = catalog }
AppGroupConfigDarwin.postConfigChanged()
}
public func setActivePolishStyleId(_ id: String) {
mutateConfiguration { config in
config.activePolishStyleId = PolishStylePackCatalog.isValidActiveID(
id,
userCatalog: config.polishStyleCatalog
) ? id : PolishStylePackCatalog.defaultID
}
AppGroupConfigDarwin.postConfigChanged()
}
public func deletePolishStylePack(id: String, at date: Date = Date()) {
mutateConfiguration { config in
config.polishStyleCatalog.recordDeletion(of: id, at: date)
if config.activePolishStyleId == id {
config.activePolishStyleId = PolishStylePackCatalog.defaultID
}
}
AppGroupConfigDarwin.postConfigChanged()
}
public func setLLMThinkingEnabled(_ enabled: Bool) {
mutateConfiguration { $0.llmThinkingEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardHistoryEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardHistoryEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardCandidateBarEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardCandidateBarEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
do {
let data = try JSONEncoder().encode(
layout.sanitized(catalog: resolvedAgentSkillCatalog)
)
defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
defaults.set(
Self.currentAgentSkillDefaultsMigrationVersion,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
} catch {
OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
}
AppGroupConfigDarwin.postConfigChanged()
}
/// Stores one encoded catalog snapshot. A successful 200 refresh can also
/// append newly published text skills without restoring previously disabled ones.
public func setOfficialSkillCatalog(
_ catalog: OfficialSkillCatalog,
installingNewDefaultSkills: Bool = false
) throws {
let validated = try catalog.validated()
let encoder = JSONEncoder()
let catalogData = try encoder.encode(validated)
var layoutData: Data?
if installingNewDefaultSkills {
let cachedIDs = Set(officialSkillCatalog.skills.map(\.id))
let addedIDs = validated.skills
.filter { $0.kind == .transform && !cachedIDs.contains($0.id) }
.map(\.id)
if !addedIDs.isEmpty {
let current = agentSkillLayout
let resolvedCatalog = AIClipboardSkillCatalog.all(
officialCatalog: validated,
userCatalog: agentUserSkillCatalog,
uiLanguage: uiLanguage
)
let updated = AIAgentSkillLayout(
enabledIDs: current.enabledIDs + addedIDs.filter {
!current.enabledIDs.contains($0)
},
confirmedShortcutIDs: current.confirmedShortcutIDs
).sanitized(catalog: resolvedCatalog)
layoutData = try encoder.encode(updated)
}
}
defaults.set(catalogData, forKey: AppGroupConfiguration.Keys.officialSkillCatalog)
if let layoutData {
defaults.set(layoutData, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
defaults.set(
Self.currentAgentSkillDefaultsMigrationVersion,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
}
defaults.synchronize()
AppGroupConfigDarwin.postConfigChanged()
}
public func setAgentUserSkillCatalog(_ catalog: AIUserSkillCatalog) {
do {
defaults.set(
try JSONEncoder().encode(catalog),
forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog
)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog encode failed: \(error.localizedDescription, privacy: .public)"
)
}
AppGroupConfigDarwin.postConfigChanged()
}
public func setPendingShortcutRun(skillID: String, titles: [String]) {
let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
if let data = AIAgentShortcutRun.encode(payload) {
defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
AIAgentShortcutRun.trace(
"appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
)
} else {
AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
}
}
public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
guard let data else {
AIAgentShortcutRun.trace("appGroup.consumePending missing")
return nil
}
guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
AIAgentShortcutRun.trace(
"appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
)
return nil
}
AIAgentShortcutRun.trace(
"appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
)
return payload
}
public func setPendingContactCreation(phoneNumber: String) {
guard let normalized = AIPhoneNumberResolver.normalized(phoneNumber),
let data = AIContactCreationHandoff.encode(
AIContactCreationPayload(phoneNumber: normalized)
) else {
defaults.removeObject(forKey: AIContactCreationHandoff.pendingKey)
return
}
defaults.set(data, forKey: AIContactCreationHandoff.pendingKey)
}
public func consumePendingContactCreation(
now: Date = Date()
) -> AIContactCreationPayload? {
let data = defaults.data(forKey: AIContactCreationHandoff.pendingKey)
defaults.removeObject(forKey: AIContactCreationHandoff.pendingKey)
guard let data else { return nil }
return AIContactCreationHandoff.decode(data, now: now)
}
private static func decodeAgentSkillLayout(
from defaults: UserDefaults,
userCatalog: AIUserSkillCatalog,
officialCatalog: OfficialSkillCatalog,
uiLanguage: AppUILanguage
) -> AIAgentSkillLayout {
let catalog = AIClipboardSkillCatalog.all(
officialCatalog: officialCatalog,
userCatalog: userCatalog,
uiLanguage: uiLanguage
)
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
defaults.set(
currentAgentSkillDefaultsMigrationVersion,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
return AIAgentSkillLayout(
enabledIDs: catalog.filter(\.isDefault).map(\.id),
confirmedShortcutIDs: []
)
}
do {
let decoded = try JSONDecoder().decode(AIAgentSkillLayout.self, from: data)
.sanitized(catalog: catalog)
let storedMigrationVersion = defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
guard storedMigrationVersion < currentAgentSkillDefaultsMigrationVersion else {
return decoded
}
let legacyDefaults = Set([
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID
])
var additionIDs = Set<String>()
if storedMigrationVersion < 1 {
// Preserve any legacy default the user explicitly turned off.
// Export and semantic skills first became defaults in v1.
additionIDs.formUnion(
AIAgentSkillLayout.defaultEnabledIDs.filter {
!legacyDefaults.contains($0)
}
)
}
if storedMigrationVersion < 2 {
additionIDs.insert(AIClipboardSkillCatalog.playfulReplyID)
}
if storedMigrationVersion < 3 {
additionIDs.formUnion(
officialCatalog.skills
.filter { $0.kind == .transform }
.map(\.id)
)
}
if storedMigrationVersion < 4 {
additionIDs.insert(AIClipboardSkillCatalog.openLinkID)
additionIDs.insert(AIClipboardSkillCatalog.summarizeWebPageID)
}
if storedMigrationVersion < 5 {
additionIDs.insert(AIClipboardSkillCatalog.callPhoneID)
additionIDs.insert(AIClipboardSkillCatalog.createContactID)
}
// v6 persists canonical IDs for the consolidated reply, summary,
// and clarification skills. `sanitized` performs the mapping.
if storedMigrationVersion < 7 {
// Product baseline: install these text skills once for existing
// layouts. Skill-management visibility is a separate policy.
additionIDs.formUnion([
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.organizeListID
])
}
if storedMigrationVersion < 8 {
additionIDs.insert(AIClipboardSkillCatalog.blessingReplyID)
}
// v9 consolidates playful and business reply into Reply. Do not
// add Reply here: `sanitized` preserves it only when any reply ID
// was enabled, so a user's explicit disabled state stays disabled.
// v10 applies the same canonical migration to Empathetic Reply.
// v11 folds invitation, task, blessing, and clarification replies
// into Reply. `sanitized` again preserves explicit disabled state.
let additions = catalog.map(\.id).filter {
additionIDs.contains($0) && !decoded.enabledIDs.contains($0)
}
let migrated = AIAgentSkillLayout(
enabledIDs: decoded.enabledIDs + additions,
confirmedShortcutIDs: decoded.confirmedShortcutIDs
).sanitized(catalog: catalog)
if let migratedData = try? JSONEncoder().encode(migrated) {
defaults.set(migratedData, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
}
defaults.set(
currentAgentSkillDefaultsMigrationVersion,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
return migrated
} catch {
OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
defaults.set(
currentAgentSkillDefaultsMigrationVersion,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
return .default
}
}
private static let currentAgentSkillDefaultsMigrationVersion = 11
private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
return .empty
}
do {
return try JSONDecoder().decode(AIUserSkillCatalog.self, from: data)
} catch {
OSGLog.config.warning(
"agentUserSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
)
return .empty
}
}
private static func decodeOfficialSkillCatalog(from defaults: UserDefaults) -> OfficialSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.officialSkillCatalog) else {
return .empty
}
do {
return try JSONDecoder().decode(OfficialSkillCatalog.self, from: data).validated()
} catch {
OSGLog.config.warning(
"officialSkillCatalog decode failed: \(error.localizedDescription, privacy: .public)"
)
return .empty
}
}
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
}
public var onboardingPage: Int {
get { configuration.onboardingPage }
set { setOnboardingPage(newValue) }
}
/// Commits onboarding to both the App Group and the reboot-durable
/// Keychain marker; callers must preserve this dual-write invariant.
public func setHasCompletedOnboarding(_ completed: Bool) {
mutateConfiguration { config in
config.hasCompletedOnboarding = completed
if completed {
config.onboardingPage = 0
}
}
// Mirror to the reboot-durable Keychain marker (keyboard-side completion).
Keychain.setOnboardingCompleted(completed)
}
public func setOnboardingPage(_ page: Int) {
mutateConfiguration { $0.onboardingPage = page }
}
// MARK: - Detected app context
public var detectedAppContext: (context: AppContext, observedAt: Date)? {
configuration.detectedAppContext(from: defaults)
}
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
var config = configuration
config.setDetectedAppContext(context, at: date, to: defaults)
}
// MARK: - Personal dictionary
public var personalDictionary: PersonalDictionary {
get { configuration.personalDictionary }
set { setPersonalDictionary(newValue) }
}
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
mutateConfiguration { $0.personalDictionary = dictionary }
AppGroupConfigDarwin.postConfigChanged()
#if os(iOS)
// Host redeploys Rime sidecar; extension picks it up next typing open.
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
#endif
}
/// Applies a read-modify-write operation to the latest persisted dictionary.
///
/// Returning `nil` cancels the write. Callers must not mutate the dictionary
/// before returning `nil`.
@discardableResult
public func updatePersonalDictionary<Result>(
_ transform: (inout PersonalDictionary) -> Result?
) -> (dictionary: PersonalDictionary, result: Result)? {
var output: (dictionary: PersonalDictionary, result: Result)?
mutateConfiguration { config in
guard let result = transform(&config.personalDictionary) else { return }
output = (config.personalDictionary, result)
}
guard let output else { return nil }
AppGroupConfigDarwin.postConfigChanged()
#if os(iOS)
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
#endif
return output
}
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
mutateConfiguration { config in
config.personalDictionary.entries.removeAll { $0.id == id }
config.personalDictionary.deletedEntryIDs[id] = date
}
AppGroupConfigDarwin.postConfigChanged()
#if os(iOS)
PersonalDictionaryRimeSync.scheduleAfterDictionaryChange()
#endif
}
public var personalDictionaryICloudSyncEnabled: Bool {
get { configuration.personalDictionaryICloudSyncEnabled }
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
}
public func setPersonalDictionaryICloudSyncEnabled(_ enabled: Bool) {
mutateConfiguration { $0.personalDictionaryICloudSyncEnabled = enabled }
}
public var settingsICloudSyncEnabled: Bool {
get { configuration.settingsICloudSyncEnabled }
set { setSettingsICloudSyncEnabled(newValue) }
}
public func setSettingsICloudSyncEnabled(_ enabled: Bool) {
mutateConfiguration { $0.settingsICloudSyncEnabled = enabled }
}
/// Timestamp of the last settings blob applied from iCloud KVS.
public var settingsCloudUpdatedAt: Date? {
let raw = defaults.double(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
guard raw > 0 else { return nil }
return Date(timeIntervalSince1970: raw)
}
// MARK: - Client
public func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?,
oobeFeature: ManagedGatewayOOBEFeature?
) -> LLMClient {
configuration.makeClient(
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature
)
}
}