8 files changed
+342
-74
lib/features/chat/providers/chat_providers.dart
+4
-1
@@ -75,7 +75,10 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
75
}
76
77
// Only auto-select for servers that support listing running models
78
- if (activeServer.type == ServerType.openRouter) return;
78
+ if (activeServer.type == ServerType.openRouter ||
79
+ activeServer.type == ServerType.openAICompatible) {
80
+ return;
81
+ }
82
83
try {
84
final Set<String> loadedModels;
lib/features/chat/views/chat_screen.dart
+13
-14
@@ -4,30 +4,29 @@ import 'package:flutter/material.dart';
4
import 'package:flutter/services.dart';
5
import 'package:flutter_riverpod/flutter_riverpod.dart';
6
import 'package:go_router/go_router.dart';
7
-import 'package:shadcn_ui/shadcn_ui.dart';
7
import 'package:hugeicons/hugeicons.dart';
9
-import 'package:localmind/l10n/app_localizations.dart';
10
-import '../../servers/views/components/server_icon_picker.dart';
11
-import '../../servers/data/models/server.dart';
8
+import '../../conversations/providers/conversation_providers.dart' as conv;
9
+import '../../models/screens/model_picker_sheet.dart';
10
+import '../../personas/providers/personas_providers.dart';
11
+import '../../servers/providers/server_providers.dart';
12
+import '../../tts/views/components/tts_player_bar.dart';
13
+import 'package:shadcn_ui/shadcn_ui.dart';
14
+
15
import '../../../core/models/enums.dart';
16
import '../../../core/providers/app_providers.dart';
14
-
17
import '../../../core/routes/app_routes.dart';
18
+import '../../../l10n/app_localizations.dart';
19
+import '../../conversations/data/models/conversation.dart';
20
+import '../../servers/data/models/server.dart';
21
+import '../../servers/views/components/server_icon_picker.dart';
22
import '../data/models/message.dart';
23
+import '../providers/chat_mcp_providers.dart';
24
import '../providers/chat_providers.dart';
25
import 'components/chat_bubble.dart';
26
import 'components/chat_input_bar.dart';
20
-import 'components/edit_message_dialog.dart';
21
-import '../providers/chat_mcp_providers.dart';
27
import 'components/chat_settings_sheet.dart';
28
+import 'components/edit_message_dialog.dart';
29
import 'components/notification_permission_banner.dart';
24
-import '../../conversations/data/models/conversation.dart';
25
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
26
- as conv;
27
-import 'package:localmind/features/models/screens/model_picker_sheet.dart';
28
-import 'package:localmind/features/personas/providers/personas_providers.dart';
29
-import 'package:localmind/features/servers/providers/server_providers.dart';
30
-import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
30
31
class ChatScreen extends ConsumerStatefulWidget {
32
const ChatScreen({super.key});
lib/features/models/screens/model_picker_sheet.dart
+28
-22
@@ -339,7 +339,10 @@ class _ModelList extends ConsumerWidget {
339
final activeServer = ref.read(activeServerProvider);
340
if (activeServer == null) return;
341
342
- if (!isLoaded) {
342
+ final supportsLoad = model.serverType == ServerType.lmStudio ||
343
+ model.serverType == ServerType.ollama;
344
+
345
+ if (supportsLoad && !isLoaded) {
346
ref.read(modelLoadingProvider.notifier).setLoading(model.id);
347
348
try {
@@ -503,29 +506,32 @@ class _ModelTile extends StatelessWidget {
506
],
507
),
508
),
506
- if (isLoaded) ...[
507
- Container(
508
- width: 15,
509
- height: 15,
510
- decoration: const BoxDecoration(
511
- color: Color(0xFF4CAF50),
512
- shape: BoxShape.circle,
509
+ if (model.serverType == ServerType.lmStudio ||
510
+ model.serverType == ServerType.ollama) ...[
511
+ if (isLoaded) ...[
512
+ Container(
513
+ width: 15,
514
+ height: 15,
515
+ decoration: const BoxDecoration(
516
+ color: Color(0xFF4CAF50),
517
+ shape: BoxShape.circle,
518
+ ),
519
),
514
- ),
515
- ],
516
- if (isLoaded) ...[
517
- IconButton(
518
- icon: Icon(
519
- Icons.power_settings_new_outlined,
520
- size: 18,
521
- color: Colors.red[400],
520
+ ],
521
+ if (isLoaded) ...[
522
+ IconButton(
523
+ icon: Icon(
524
+ Icons.power_settings_new_outlined,
525
+ size: 18,
526
+ color: Colors.red[400],
527
+ ),
528
+ onPressed: onUnload,
529
+ padding: EdgeInsets.zero,
530
+ constraints: const BoxConstraints(),
531
+ tooltip: l10n.unload_from_server,
532
),
523
- onPressed: onUnload,
524
- padding: EdgeInsets.zero,
525
- constraints: const BoxConstraints(),
526
- tooltip: l10n.unload_from_server,
527
- ),
528
- const SizedBox(width: 8),
533
+ const SizedBox(width: 8),
534
+ ],
535
],
536
if (isSelected)
537
Icon(Icons.check_circle, color: accent, size: 22)
lib/features/servers/data/server_api_service.dart
+56
-28
@@ -63,7 +63,8 @@ class ServerApiService {
63
}
64
65
Future<Set<String>> fetchRunningModels(Server server) async {
66
- if (server.type == ServerType.openRouter) {
66
+ if (server.type == ServerType.openRouter ||
67
+ server.type == ServerType.openAICompatible) {
68
return {};
69
}
70
@@ -191,16 +192,18 @@ class ServerApiService {
192
193
Set<String> _parseRunningOpenAICompatibleModels(dynamic data) {
194
final runningModels = <String>{};
194
- if (data['models'] != null) {
195
- for (final item in data['models']) {
196
- final id = item['key'] as String? ??
197
- item['name'] as String? ??
198
- item['id'] as String? ??
195
+ final modelItems = data['models'] ?? data['data'];
196
+ if (modelItems != null && modelItems is List) {
197
+ for (final item in modelItems) {
198
+ if (item is! Map) continue;
199
+ final id = item['key']?.toString() ??
200
+ item['name']?.toString() ??
201
+ item['id']?.toString() ??
202
'';
203
if (id.isEmpty) continue;
204
202
- final loadedInstances = item['loaded_instances'] as List?;
203
- if (loadedInstances != null) {
205
+ final loadedInstances = item['loaded_instances'];
206
+ if (loadedInstances is List) {
207
if (loadedInstances.isNotEmpty) {
208
runningModels.add(id);
209
}
@@ -237,46 +240,63 @@ class ServerApiService {
240
// Build a lookup from the OpenAI-style 'data' array for metadata
241
// that may be missing from the 'models' array (e.g. llama.cpp)
242
final Map<String, Map<String, dynamic>> metaById = {};
240
- if (data['data'] != null) {
243
+ if (data['data'] != null && data['data'] is List) {
244
for (final item in data['data']) {
242
- final id = item['id'] as String? ?? '';
243
- if (id.isNotEmpty) {
244
- metaById[id] = (item['meta'] as Map<String, dynamic>?) ?? {};
245
+ if (item is Map) {
246
+ final id = item['id']?.toString() ?? '';
247
+ if (id.isNotEmpty) {
248
+ metaById[id] = (item['meta'] as Map<String, dynamic>?) ?? {};
249
+ }
250
}
251
}
252
}
253
249
- if (data['models'] != null) {
250
- for (final item in data['models']) {
251
- final id = item['key'] as String? ??
252
- item['name'] as String? ??
253
- item['id'] as String? ??
254
+ final modelItems = data['models'] ?? data['data'];
255
+ if (modelItems != null && modelItems is List) {
256
+ for (final item in modelItems) {
257
+ if (item is! Map) continue;
258
+ final id = item['key']?.toString() ??
259
+ item['name']?.toString() ??
260
+ item['id']?.toString() ??
261
'';
262
if (id.isEmpty) continue;
263
257
- final displayName = item['display_name'] as String?;
264
+ final displayName = item['display_name']?.toString();
265
final quantization = item['quantization'];
259
- final paramsString = item['params_string'] as String?;
266
+ final paramsString = item['params_string']?.toString();
267
261
- final meta = metaById[id];
262
- if (meta == null && metaById.isNotEmpty) {
268
+ final meta = metaById[id] ?? (item['meta'] as Map<String, dynamic>?);
269
+ if (meta == null && metaById.isNotEmpty && data['models'] != null) {
270
Log.warning('Metadata join failed for model: $id');
271
}
272
final finalMeta = meta ?? {};
273
274
+ String? quantName;
275
+ if (quantization is Map) {
276
+ quantName = quantization['name']?.toString();
277
+ } else if (quantization is String) {
278
+ quantName = quantization;
279
+ }
280
+
281
+ final archValue = item['architecture'];
282
+ String? archName;
283
+ if (archValue is String) {
284
+ archName = archValue;
285
+ }
286
+
287
models.add(
288
ModelInfo(
289
id: id,
290
name: displayName ?? _formatModelName(id),
271
- description: item['description'] as String?,
291
+ description: item['description']?.toString(),
292
parameterCount: _parseParameterString(paramsString) ??
293
_paramCountFromMeta(finalMeta['n_params']),
274
- contextLength: item['max_context_length'] as int? ??
275
- finalMeta['n_ctx_train'] as int?,
276
- fileSize: item['size_bytes'] as int? ??
277
- finalMeta['size'] as int?,
278
- quantization: quantization?['name'] as String?,
279
- architecture: item['architecture'] as String?,
294
+ contextLength: _toInt(item['max_context_length']) ??
295
+ _toInt(finalMeta['n_ctx_train']),
296
+ fileSize: _toInt(item['size_bytes']) ??
297
+ _toInt(finalMeta['size']),
298
+ quantization: quantName,
299
+ architecture: archName,
300
serverType: server.type,
301
serverId: server.id,
302
),
@@ -286,6 +306,14 @@ class ServerApiService {
306
return models;
307
}
308
309
+ int? _toInt(dynamic value) {
310
+ if (value == null) return null;
311
+ if (value is int) return value;
312
+ if (value is double) return value.toInt();
313
+ if (value is String) return int.tryParse(value);
314
+ return null;
315
+ }
316
+
317
double? _paramCountFromMeta(dynamic nParams) {
318
if (nParams == null) return null;
319
if (nParams is int) return nParams / 1000000000;
lib/features/servers/providers/server_providers.dart
+5
@@ -90,6 +90,11 @@ final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
90
: {};
91
}
92
93
+ if (server.type == ServerType.openAICompatible ||
94
+ server.type == ServerType.openRouter) {
95
+ return <String>{};
96
+ }
97
+
98
final apiService = ref.watch(serverApiServiceProvider);
99
try {
100
return await apiService.fetchRunningModels(server);
pubspec.lock
+8
-8
@@ -801,10 +801,10 @@ packages:
801
dependency: transitive
802
description:
803
name: matcher
804
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
804
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
805
url: "https://pub.dev"
806
source: hosted
807
- version: "0.12.19"
807
+ version: "0.12.18"
808
material_color_utilities:
809
dependency: transitive
810
description:
@@ -1351,26 +1351,26 @@ packages:
1351
dependency: transitive
1352
description:
1353
name: test
1354
- sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1354
+ sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1355
url: "https://pub.dev"
1356
source: hosted
1357
- version: "1.30.0"
1357
+ version: "1.29.0"
1358
test_api:
1359
dependency: transitive
1360
description:
1361
name: test_api
1362
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1362
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1363
url: "https://pub.dev"
1364
source: hosted
1365
- version: "0.7.10"
1365
+ version: "0.7.9"
1366
test_core:
1367
dependency: transitive
1368
description:
1369
name: test_core
1370
- sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1370
+ sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1371
url: "https://pub.dev"
1372
source: hosted
1373
- version: "0.6.16"
1373
+ version: "0.6.15"
1374
theme_extensions_builder_annotation:
1375
dependency: transitive
1376
description:
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.3.1+31
19
+version: 1.3.2+32
20
21
environment:
22
sdk: ^3.11.0
test/features/servers/server_api_service_test.dart
new
+227
@@ -0,0 +1,227 @@
1
+import 'package:dio/dio.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/features/servers/data/models/server.dart';
5
+import 'package:localmind/features/servers/data/server_api_service.dart';
6
+
7
+class TestInterceptor extends Interceptor {
8
+ final dynamic responseData;
9
+ TestInterceptor(this.responseData);
10
+
11
+ @override
12
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
13
+ handler.resolve(Response(
14
+ requestOptions: options,
15
+ data: responseData,
16
+ statusCode: 200,
17
+ ));
18
+ }
19
+}
20
+
21
+void main() {
22
+ group('ServerApiService - OpenAI/llama.cpp model list parsing', () {
23
+ late Server testServer;
24
+
25
+ setUp(() {
26
+ testServer = Server(
27
+ id: 'test-openai',
28
+ name: 'Test OpenAI Compatible',
29
+ type: ServerType.openAICompatible,
30
+ host: 'localhost',
31
+ port: 8080,
32
+ createdAt: DateTime.now(),
33
+ lastConnectedAt: DateTime.now(),
34
+ );
35
+ });
36
+
37
+ test('parses models successfully using fallback data array (llama.cpp / OpenAI standard)', () async {
38
+ final mockData = {
39
+ "object": "list",
40
+ "data": [
41
+ {
42
+ "id": "meta-llama/Llama-3-8B-Instruct",
43
+ "object": "model",
44
+ "created": 1677610602,
45
+ "owned_by": "openai",
46
+ "meta": {
47
+ "n_ctx_train": 8192,
48
+ "n_params": 8000000000,
49
+ "size": 4800000000
50
+ }
51
+ }
52
+ ]
53
+ };
54
+
55
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
56
+ final service = ServerApiService(dio);
57
+
58
+ final models = await service.fetchModels(testServer);
59
+
60
+ expect(models, hasLength(1));
61
+ final model = models.first;
62
+ expect(model.id, "meta-llama/Llama-3-8B-Instruct");
63
+ expect(model.name, "Meta Llama/Llama 3 8B Instruct");
64
+ expect(model.contextLength, 8192);
65
+ expect(model.parameterCount, 8.0); // 8000000000 / 1000000000
66
+ expect(model.fileSize, 4800000000);
67
+ expect(model.serverType, ServerType.openAICompatible);
68
+ expect(model.serverId, testServer.id);
69
+ });
70
+
71
+ test('parses models successfully using custom models array (LM Studio)', () async {
72
+ final mockData = {
73
+ "models": [
74
+ {
75
+ "key": "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF",
76
+ "display_name": "Llama 3 8B Instruct",
77
+ "description": "LM Studio test model",
78
+ "params_string": "8B",
79
+ "max_context_length": 4096,
80
+ "size_bytes": 5000000000,
81
+ "quantization": {
82
+ "name": "Q4_K_M"
83
+ },
84
+ "architecture": "llama"
85
+ }
86
+ ]
87
+ };
88
+
89
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
90
+ final service = ServerApiService(dio);
91
+
92
+ final models = await service.fetchModels(testServer);
93
+
94
+ expect(models, hasLength(1));
95
+ final model = models.first;
96
+ expect(model.id, "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF");
97
+ expect(model.name, "Llama 3 8B Instruct");
98
+ expect(model.description, "LM Studio test model");
99
+ expect(model.parameterCount, 8.0);
100
+ expect(model.contextLength, 4096);
101
+ expect(model.fileSize, 5000000000);
102
+ expect(model.quantization, "Q4_K_M");
103
+ expect(model.architecture, "llama");
104
+ });
105
+
106
+ test('handles running models with fallback data array (llama.cpp)', () async {
107
+ final mockData = {
108
+ "object": "list",
109
+ "data": [
110
+ {
111
+ "id": "active-model",
112
+ "object": "model"
113
+ }
114
+ ]
115
+ };
116
+
117
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
118
+ final service = ServerApiService(dio);
119
+
120
+ final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
121
+ final running = await service.fetchRunningModels(lmStudioServer);
122
+
123
+ expect(running, contains("active-model"));
124
+ });
125
+
126
+ test('handles running models with custom models array and loaded instances (LM Studio)', () async {
127
+ final mockData = {
128
+ "models": [
129
+ {
130
+ "key": "active-model-lm",
131
+ "loaded_instances": [
132
+ {"id": "inst_1"}
133
+ ]
134
+ },
135
+ {
136
+ "key": "inactive-model-lm",
137
+ "loaded_instances": []
138
+ }
139
+ ]
140
+ };
141
+
142
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
143
+ final service = ServerApiService(dio);
144
+
145
+ final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
146
+ final running = await service.fetchRunningModels(lmStudioServer);
147
+
148
+ expect(running, contains("active-model-lm"));
149
+ expect(running, isNot(contains("inactive-model-lm")));
150
+ });
151
+
152
+ test('handles custom string quantization value gracefully', () async {
153
+ final mockData = {
154
+ "data": [
155
+ {
156
+ "id": "model-with-string-quant",
157
+ "quantization": "Q5_K_M"
158
+ }
159
+ ]
160
+ };
161
+
162
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
163
+ final service = ServerApiService(dio);
164
+
165
+ final models = await service.fetchModels(testServer);
166
+ expect(models.first.quantization, "Q5_K_M");
167
+ });
168
+
169
+ test('handles actual user llama.cpp endpoint output successfully with Map architecture', () async {
170
+ final mockData = {
171
+ "data": [
172
+ {
173
+ "id": "Gemma-4-31B:IQ4_XS",
174
+ "aliases": [],
175
+ "tags": [],
176
+ "object": "model",
177
+ "owned_by": "llamacpp",
178
+ "created": 1779851034,
179
+ "status": {
180
+ "value": "unloaded",
181
+ "args": [
182
+ "D:\\llamacpp\\llama-server.exe",
183
+ "--host",
184
+ "127.0.0.1",
185
+ "--jinja",
186
+ "--mlock",
187
+ "--no-mmap",
188
+ "--port",
189
+ "0",
190
+ "--alias",
191
+ "Gemma-4-31B:IQ4_XS",
192
+ "--ctx-size",
193
+ "65536",
194
+ "--cache-type-k",
195
+ "q4_0",
196
+ "--cache-type-v",
197
+ "q4_0",
198
+ "--flash-attn",
199
+ "true",
200
+ "--model",
201
+ "./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf",
202
+ "--parallel",
203
+ "1",
204
+ "--reasoning",
205
+ "off"
206
+ ],
207
+ "preset": "[Gemma-4-31B:IQ4_XS]\njinja = true\nmlock = 1\nmmap = 0\nctx-size = 65536\ncache-type-k = q4_0\ncache-type-v = q4_0\nflash-attn = true\nmodel = ./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf\nparallel = 1\nreasoning = off\n\n"
208
+ },
209
+ "architecture": {
210
+ "input_modalities": ["text"],
211
+ "output_modalities": ["text"]
212
+ }
213
+ }
214
+ ],
215
+ "object": "list"
216
+ };
217
+
218
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
219
+ final service = ServerApiService(dio);
220
+
221
+ final models = await service.fetchModels(testServer);
222
+ expect(models, hasLength(1));
223
+ expect(models.first.id, "Gemma-4-31B:IQ4_XS");
224
+ expect(models.first.architecture, isNull);
225
+ });
226
+ });
227
+}