Skip to content

Commit 3662b23

Browse files
committed
Fix custom model settings state and validation
- Write back only the model fields when the edit sheet reads from disk, instead of rebuilding the API key and base URL pickers, which raced with an empty tag - Give BuiltInSuggestionFeatureProvider.customModel the raw value 1000. A persisted SuggestionFeatureProvider of Int 2 now fails to decode and falls back to the default provider, so the user has to pick Custom Model once - Keep the key names of the standalone app without migrating its configuration, and treat the old chatModel value as an invalid model - Snapshot modelId, chatModels and strategyId once in the coordinator, save through userDefaults behind a non-empty check, and forward cancellation to the inner task - Populate the Completion picker from the completions list and the FIM picker from the codestral list, and drop the unreachable googleAI view - Fold the three model field views into SharedModelFields, and move RequestStrategyOption and the model lists out of the engine, so HostApp no longer depends on CustomSuggestionService Tests cover saving from CompletionModelEdit and the raw 1000 round trip for SuggestionFeatureProvider. The sheet pickers were not verified in the GUI.
1 parent b5a24bf commit 3662b23

20 files changed

Lines changed: 501 additions & 349 deletions

‎Core/Package.swift‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,9 @@ let package = Package(
122122
.target(
123123
name: "HostApp",
124124
dependencies: [
125-
.product(name: "CustomSuggestionService", package: "Tool"),
126125
"Client",
127126
"LaunchAgentManager",
127+
"UpdateChecker",
128128
"PlusFeatureFlag",
129129
.product(name: "SuggestionProvider", package: "Tool"),
130130
.product(name: "Toast", package: "Tool"),
@@ -140,6 +140,15 @@ let package = Package(
140140
"ProHostApp",
141141
])
142142
),
143+
.testTarget(
144+
name: "HostAppTests",
145+
dependencies: [
146+
"HostApp",
147+
.product(name: "Preferences", package: "Tool"),
148+
.product(name: "Toast", package: "Tool"),
149+
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
150+
]
151+
),
143152

144153
// MARK: - Suggestion Service
145154

‎Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift‎

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import AIModel
22
import ComposableArchitecture
3-
import CustomSuggestionService
43
import Preferences
5-
import Dependencies
6-
import SwiftUI
4+
import Toast
75

86
@Reducer
97
struct CompletionModelEdit {
@@ -16,11 +14,11 @@ struct CompletionModelEdit {
1614
var apiKeyName: String { apiKeySelection.apiKeyName }
1715
var baseURL: String { baseURLSelection.baseURL }
1816
var availableModelNames: [String] = []
19-
var availableAPIKeys: [String] = []
2017
var suggestedMaxTokens: Int?
2118
var apiKeySelection: APIKeySelection.State = .init()
2219
var baseURLSelection: BaseURLSelection.State = .init()
2320
var ollamaKeepAlive: String = ""
21+
var didSave = false
2422
}
2523

2624
enum Action: Equatable, BindableAction {
@@ -35,11 +33,7 @@ struct CompletionModelEdit {
3533
}
3634

3735
@Dependency(\.toast) var toast
38-
@Dependency(\.apiKeyKeychain) var keychain
39-
40-
enum DebounceID: Hashable {
41-
case save
42-
}
36+
@Dependency(\.userDefaults) var userDefaults
4337

4438
var body: some Reducer<State, Action> {
4539
BindingReducer()
@@ -55,27 +49,43 @@ struct CompletionModelEdit {
5549
Reduce { state, action in
5650
switch action {
5751
case .appear:
52+
state.didSave = false
5853
return .run { send in
5954
await send(.readCustomModelFromDisk)
6055
}
6156

6257
case .saveButtonClicked:
63-
let model = CompletionModel(state: state)
64-
return .run { _ in
65-
UserDefaults.shared.set(model, for: \.customSuggestionCompletionModel)
58+
let modelName = state.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
59+
let baseURL = state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
60+
guard !modelName.isEmpty else {
61+
toast("Model name cannot be empty", .error)
62+
return .none
6663
}
64+
guard !baseURL.isEmpty else {
65+
toast("Base URL cannot be empty", .error)
66+
return .none
67+
}
68+
userDefaults.set(CompletionModel(state: state), for: \.customSuggestionCompletionModel)
69+
state.didSave = true
70+
return .none
6771

6872
case .refreshAvailableModelNames:
6973
if state.format == .openAI {
70-
state.availableModelNames = CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases
71-
.map(\.rawValue)
74+
state.availableModelNames = KnownCompletionModels.allCases.map(\.rawValue)
7275
}
7376

7477
return .none
7578

7679
case .readCustomModelFromDisk:
77-
let model = UserDefaults.shared.value(for: \.customSuggestionCompletionModel)
78-
state = model.toState()
80+
let model = userDefaults.value(for: \.customSuggestionCompletionModel)
81+
state.id = model.id
82+
state.format = model.format
83+
state.maxTokens = model.info.maxTokens
84+
state.modelName = model.info.modelName
85+
state.apiKeySelection.apiKeyName = model.info.apiKeyName
86+
state.baseURLSelection.baseURL = model.info.baseURL
87+
state.baseURLSelection.isFullURL = model.info.isFullURL
88+
state.ollamaKeepAlive = model.info.ollamaInfo.keepAlive
7989

8090
return .run { send in
8191
await send(.checkSuggestedMaxTokens)
@@ -85,7 +95,7 @@ struct CompletionModelEdit {
8595
case .checkSuggestedMaxTokens:
8696
switch state.format {
8797
case .openAI:
88-
if let knownModel = CustomSuggestionService.OpenAIService.CompletionModels(rawValue: state.modelName) {
98+
if let knownModel = KnownCompletionModels(rawValue: state.modelName) {
8999
state.suggestedMaxTokens = knownModel.maxToken
90100
} else {
91101
state.suggestedMaxTokens = nil
@@ -153,4 +163,3 @@ extension CompletionModel {
153163
)
154164
}
155165
}
156-

‎Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift‎

Lines changed: 37 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import AIModel
22
import ComposableArchitecture
3-
import CustomSuggestionService
4-
import Preferences
53
import SwiftUI
64

75
@MainActor
@@ -44,7 +42,6 @@ struct CompletionModelEditView: View {
4442

4543
Button(action: {
4644
store.send(.saveButtonClicked)
47-
dismiss()
4845
}) {
4946
Text("Save")
5047
}
@@ -57,6 +54,9 @@ struct CompletionModelEditView: View {
5754
.onAppear {
5855
store.send(.appear)
5956
}
57+
.onChange(of: store.didSave) { didSave in
58+
if didSave { dismiss() }
59+
}
6060
.fixedSize(horizontal: false, vertical: true)
6161
}
6262
}
@@ -93,7 +93,7 @@ struct CompletionModelEditView: View {
9393
prompt: Text?,
9494
@ViewBuilder trailingContent: @escaping () -> V
9595
) -> some View {
96-
BaseURLPicker(
96+
BaseURLTextField(
9797
title: title,
9898
prompt: prompt,
9999
store: store.scope(
@@ -111,81 +111,37 @@ struct CompletionModelEditView: View {
111111
baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
112112
}
113113

114-
var maxTokensTextField: some View {
115-
HStack {
116-
let textFieldBinding = Binding(
117-
get: { String(store.maxTokens) },
118-
set: {
119-
if let selectionMaxToken = Int($0) {
120-
$store.maxTokens.wrappedValue = selectionMaxToken
121-
} else {
122-
$store.maxTokens.wrappedValue = 0
123-
}
124-
}
125-
)
126-
127-
TextField(text: textFieldBinding) {
128-
Text("Context Window")
129-
.multilineTextAlignment(.trailing)
130-
}
131-
.overlay(alignment: .trailing) {
132-
Stepper(
133-
value: $store.maxTokens,
134-
in: 0...Int.max,
135-
step: 100
136-
) {
137-
EmptyView()
138-
}
139-
}
140-
.foregroundColor({
141-
guard let max = store.suggestedMaxTokens else {
142-
return .primary
143-
}
144-
if store.maxTokens > max {
145-
return .red
146-
}
147-
return .primary
148-
}() as Color)
149-
150-
if let max = store.suggestedMaxTokens {
151-
Text("Max: \(max)")
152-
}
153-
}
154-
}
155-
156-
@ViewBuilder
157-
var apiKeyNamePicker: some View {
158-
APIKeyPicker(store: store.scope(
159-
state: \.apiKeySelection,
160-
action: \.apiKeySelection
161-
))
162-
}
163-
164114
@ViewBuilder
165115
var openAI: some View {
166116
baseURLTextField(prompt: Text("https://api.openai.com")) {
167117
Text("/v1/completions")
168118
}
169-
apiKeyNamePicker
119+
ApiKeyNamePicker(store: store.scope(
120+
state: \.apiKeySelection,
121+
action: \.apiKeySelection
122+
))
170123

171124
TextField("Model Name", text: $store.modelName)
172125
.overlay(alignment: .trailing) {
173126
Picker(
174127
"",
175128
selection: $store.modelName,
176129
content: {
177-
if CustomSuggestionService.OpenAIService.ChatCompletionModels(rawValue: store.modelName) == nil {
130+
if !store.availableModelNames.contains(store.modelName) {
178131
Text("Custom Model").tag(store.modelName)
179132
}
180-
ForEach(CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases, id: \.self) { model in
181-
Text(model.rawValue).tag(model.rawValue)
133+
ForEach(store.availableModelNames, id: \.self) { model in
134+
Text(model).tag(model)
182135
}
183136
}
184137
)
185138
.frame(width: 20)
186139
}
187140

188-
maxTokensTextField
141+
MaxTokensTextField(
142+
maxTokens: $store.maxTokens,
143+
suggestedMaxTokens: store.suggestedMaxTokens
144+
)
189145

190146
VStack(alignment: .leading, spacing: 8) {
191147
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -198,11 +154,17 @@ struct CompletionModelEditView: View {
198154
@ViewBuilder
199155
var azureOpenAI: some View {
200156
baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
201-
apiKeyNamePicker
157+
ApiKeyNamePicker(store: store.scope(
158+
state: \.apiKeySelection,
159+
action: \.apiKeySelection
160+
))
202161

203162
TextField("Deployment Name", text: $store.modelName)
204163

205-
maxTokensTextField
164+
MaxTokensTextField(
165+
maxTokens: $store.maxTokens,
166+
suggestedMaxTokens: store.suggestedMaxTokens
167+
)
206168
}
207169

208170
@ViewBuilder
@@ -227,35 +189,17 @@ struct CompletionModelEditView: View {
227189
Text("/v1/completions")
228190
}
229191
}
230-
apiKeyNamePicker
231-
232-
TextField("Model Name", text: $store.modelName)
233-
234-
maxTokensTextField
235-
}
236-
237-
@ViewBuilder
238-
var googleAI: some View {
239-
apiKeyNamePicker
192+
ApiKeyNamePicker(store: store.scope(
193+
state: \.apiKeySelection,
194+
action: \.apiKeySelection
195+
))
240196

241197
TextField("Model Name", text: $store.modelName)
242-
.overlay(alignment: .trailing) {
243-
Picker(
244-
"",
245-
selection: $store.modelName,
246-
content: {
247-
if GoogleGeminiService.KnownModels(rawValue: store.modelName) == nil {
248-
Text("Custom Model").tag(store.modelName)
249-
}
250-
ForEach(GoogleGeminiService.KnownModels.allCases, id: \.self) { model in
251-
Text(model.rawValue).tag(model.rawValue)
252-
}
253-
}
254-
)
255-
.frame(width: 20)
256-
}
257198

258-
maxTokensTextField
199+
MaxTokensTextField(
200+
maxTokens: $store.maxTokens,
201+
suggestedMaxTokens: store.suggestedMaxTokens
202+
)
259203
}
260204

261205
@ViewBuilder
@@ -269,12 +213,15 @@ struct CompletionModelEditView: View {
269213

270214
TextField("Model Name", text: $store.modelName)
271215

272-
maxTokensTextField
273-
216+
MaxTokensTextField(
217+
maxTokens: $store.maxTokens,
218+
suggestedMaxTokens: store.suggestedMaxTokens
219+
)
220+
274221
TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
275222
Text("Keep Alive")
276223
}
277-
224+
278225
VStack(alignment: .leading, spacing: 8) {
279226
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
280227
" For more details, please visit [https://ollama.com](https://ollama.com)"
@@ -321,4 +268,3 @@ struct CompletionModelEditView: View {
321268
)
322269
)
323270
}
324-

‎Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import AIModel
22
import ComposableArchitecture
3-
import CustomSuggestionService
43
import Preferences
54
import SwiftUI
65

0 commit comments

Comments
 (0)