@tduncan / Cortana / commits / 1273a1b

refactor: improve OpenAI-compatible model parsing robustness and fixes #14

abdulmominsakib committed May 27, 2026 at 09:27 UTC 1273a1b69d971739eb4846a36eea9c7fe0c40ef6
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 +}