main
dart 1,343 lines 42.2 KB
Raw
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 import 'dart:convert';
7
8 class TestInterceptor extends Interceptor {
9 final dynamic responseData;
10 TestInterceptor(this.responseData);
11
12 @override
13 void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
14 handler.resolve(
15 Response(requestOptions: options, data: responseData, statusCode: 200),
16 );
17 }
18 }
19
20 class RequestRecordingInterceptor extends Interceptor {
21 final List<RequestOptions> requests = [];
22 final int statusCode;
23 RequestRecordingInterceptor({this.statusCode = 200});
24
25 @override
26 void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
27 requests.add(options);
28 handler.resolve(
29 Response(
30 requestOptions: options,
31 statusCode: statusCode,
32 data: {'status': 'ok'},
33 ),
34 );
35 }
36 }
37
38 class FailingInterceptor extends Interceptor {
39 @override
40 void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
41 handler.reject(
42 DioException(
43 requestOptions: options,
44 type: DioExceptionType.connectionError,
45 message: 'connection refused',
46 ),
47 );
48 }
49 }
50
51 class RoutingInterceptor extends Interceptor {
52 RoutingInterceptor(this.responses);
53
54 /// Maps a request key ("METHOD /path" or "METHOD /path:body-name") to a
55 /// mock response body. Keys match against the request's path suffix so
56 /// tests don't need a baseUrl.
57 final Map<String, dynamic> responses;
58
59 @override
60 void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
61 final key = _matchKey(options);
62 final body = key == null ? null : responses[key];
63 if (body == null) {
64 handler.reject(
65 DioException(
66 requestOptions: options,
67 type: DioExceptionType.badResponse,
68 message: 'No mock for ${options.method} ${options.path}',
69 ),
70 );
71 return;
72 }
73 handler.resolve(
74 Response(requestOptions: options, statusCode: 200, data: body),
75 );
76 }
77
78 String? _matchKey(RequestOptions options) {
79 final method = options.method.toUpperCase();
80 final path = options.path;
81 final base = '$method $path';
82 final suffix = '$method ${_suffix(path)}';
83 for (final candidate in responses.keys) {
84 if (candidate == base || candidate == suffix) return candidate;
85 }
86 final name = _bodyName(options.data);
87 if (name == null) return null;
88 final baseKey = '$base:$name';
89 if (responses.containsKey(baseKey)) return baseKey;
90 return '$suffix:$name';
91 }
92
93 String _suffix(String path) {
94 final idx = path.indexOf('/api/');
95 if (idx < 0) return path;
96 return path.substring(idx);
97 }
98
99 String? _bodyName(dynamic data) {
100 if (data is Map) {
101 return data['model']?.toString() ?? data['name']?.toString();
102 }
103 if (data is String && data.isNotEmpty) {
104 try {
105 final decoded = jsonDecode(data);
106 if (decoded is Map) {
107 return decoded['model']?.toString() ?? decoded['name']?.toString();
108 }
109 } catch (_) {
110 // Not JSON — leave name null.
111 }
112 }
113 return null;
114 }
115 }
116
117 void main() {
118 group('ServerApiService - OpenAI/llama.cpp model list parsing', () {
119 late Server testServer;
120
121 setUp(() {
122 testServer = Server(
123 id: 'test-openai',
124 name: 'Test OpenAI Compatible',
125 type: ServerType.openAICompatible,
126 host: 'localhost',
127 port: 8080,
128 createdAt: DateTime.now(),
129 lastConnectedAt: DateTime.now(),
130 );
131 });
132
133 test(
134 'parses models successfully using fallback data array (llama.cpp / OpenAI standard)',
135 () async {
136 final mockData = {
137 "object": "list",
138 "data": [
139 {
140 "id": "meta-llama/Llama-3-8B-Instruct",
141 "object": "model",
142 "created": 1677610602,
143 "owned_by": "openai",
144 "meta": {
145 "n_ctx_train": 8192,
146 "n_params": 8000000000,
147 "size": 4800000000,
148 },
149 },
150 ],
151 };
152
153 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
154 final service = ServerApiService(dio);
155
156 final models = await service.fetchModels(testServer);
157
158 expect(models, hasLength(1));
159 final model = models.first;
160 expect(model.id, "meta-llama/Llama-3-8B-Instruct");
161 expect(model.name, "Meta Llama/Llama 3 8B Instruct");
162 expect(model.contextLength, 8192);
163 expect(model.parameterCount, 8.0); // 8000000000 / 1000000000
164 expect(model.fileSize, 4800000000);
165 expect(model.serverType, ServerType.openAICompatible);
166 expect(model.serverId, testServer.id);
167 },
168 );
169
170 test(
171 'parses models successfully using custom models array (LM Studio)',
172 () async {
173 final mockData = {
174 "models": [
175 {
176 "key": "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF",
177 "display_name": "Llama 3 8B Instruct",
178 "description": "LM Studio test model",
179 "params_string": "8B",
180 "max_context_length": 4096,
181 "size_bytes": 5000000000,
182 "quantization": {"name": "Q4_K_M"},
183 "architecture": "llama",
184 },
185 ],
186 };
187
188 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
189 final service = ServerApiService(dio);
190
191 final models = await service.fetchModels(testServer);
192
193 expect(models, hasLength(1));
194 final model = models.first;
195 expect(model.id, "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF");
196 expect(model.name, "Llama 3 8B Instruct");
197 expect(model.description, "LM Studio test model");
198 expect(model.parameterCount, 8.0);
199 expect(model.contextLength, 4096);
200 expect(model.fileSize, 5000000000);
201 expect(model.quantization, "Q4_K_M");
202 expect(model.architecture, "llama");
203 },
204 );
205
206 test('parses model capabilities from LM Studio models array', () async {
207 final mockData = {
208 "models": [
209 {
210 "key": "google/gemma-4-e2b",
211 "display_name": "Gemma 4 E2B",
212 "capabilities": {"vision": true, "trained_for_tool_use": true},
213 },
214 {
215 "key": "mistralai/ministral-3-14b-reasoning",
216 "display_name": "Ministral 3 14B Reasoning",
217 "capabilities": {
218 "vision": true,
219 "trained_for_tool_use": true,
220 "reasoning": {
221 "allowed_options": ["off", "on"],
222 "default": "on",
223 },
224 },
225 },
226 {
227 "key": "mradermacher/kimi-vl-a3b-thinking-2506-i1",
228 "display_name": "Kimi VL A3B Thinking 2506 I1",
229 "capabilities": {"vision": false, "trained_for_tool_use": false},
230 },
231 ],
232 };
233
234 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
235 final service = ServerApiService(dio);
236
237 final models = await service.fetchModels(testServer);
238
239 expect(models, hasLength(3));
240
241 expect(models[0].supportsVision, isTrue);
242 expect(models[0].supportsToolUse, isTrue);
243 expect(models[0].supportsReasoning, isFalse);
244
245 expect(models[1].supportsVision, isTrue);
246 expect(models[1].supportsToolUse, isTrue);
247 expect(models[1].supportsReasoning, isTrue);
248
249 expect(models[2].supportsVision, isFalse);
250 expect(models[2].supportsToolUse, isFalse);
251 expect(models[2].supportsReasoning, isFalse);
252 });
253
254 test(
255 'only infers standard reasoning-effort models without capabilities',
256 () async {
257 final mockData = {
258 "object": "list",
259 "data": [
260 {"id": "o1-mini", "object": "model"},
261 {"id": "o3-mini", "object": "model"},
262 {"id": "openai/gpt-5-mini", "object": "model"},
263 {"id": "deepseek-reasoner", "object": "model"},
264 {
265 "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
266 "object": "model",
267 },
268 {"id": "qwq-32b", "object": "model"},
269 {"id": "meta-llama/Llama-3.1-8B-Instruct", "object": "model"},
270 ],
271 };
272
273 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
274 final service = ServerApiService(dio);
275
276 final models = await service.fetchModels(testServer);
277
278 expect(models, hasLength(7));
279
280 // o1-mini
281 expect(models[0].supportsReasoning, isTrue);
282 expect(models[0].supportedReasoningEfforts, ['low', 'medium', 'high']);
283 expect(models[0].defaultReasoningEffort, 'medium');
284 expect(models[0].reasoningMandatory, isFalse);
285
286 // o3-mini
287 expect(models[1].supportsReasoning, isTrue);
288 expect(models[1].supportedReasoningEfforts, ['low', 'medium', 'high']);
289 expect(models[1].defaultReasoningEffort, 'medium');
290
291 // gpt-5-mini
292 expect(models[2].supportsReasoning, isTrue);
293 expect(models[2].supportedReasoningEfforts, ['low', 'medium', 'high']);
294
295 // Provider-specific reasoning models need an explicit capability
296 // payload so LocalMind does not send unsupported request fields.
297 expect(models[3].supportsReasoning, isFalse);
298 expect(models[4].supportsReasoning, isFalse);
299 expect(models[5].supportsReasoning, isFalse);
300
301 expect(models[6].supportsReasoning, isFalse);
302 expect(models[6].supportedReasoningEfforts, isNull);
303 },
304 );
305
306 test('parses LM Studio allowed_options/default/mandatory', () async {
307 final mockData = {
308 "models": [
309 {
310 "key": "meta/muse-glimmer",
311 "display_name": "Muse Glimmer",
312 "capabilities": {
313 "reasoning": {
314 "allowed_options": ["low", "medium", "high", "xhigh"],
315 "default": "medium",
316 },
317 },
318 },
319 {
320 "key": "google/gemma-4-e2b",
321 "display_name": "Gemma 4 E2B",
322 "capabilities": {
323 "reasoning": {
324 "allowed_options": ["off", "on"],
325 "default": "on",
326 },
327 },
328 },
329 {
330 "key": "deepseek-r1",
331 "display_name": "DeepSeek R1",
332 "capabilities": {
333 "reasoning": {
334 "allowed_options": ["on"],
335 "default": "on",
336 },
337 },
338 },
339 {
340 "key": "plain-model",
341 "display_name": "Plain",
342 "capabilities": {"vision": false},
343 },
344 ],
345 };
346
347 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
348 final service = ServerApiService(dio);
349
350 final models = await service.fetchModels(testServer);
351 expect(models, hasLength(4));
352 final byId = {for (final m in models) m.id: m};
353
354 final glimmer = byId['meta/muse-glimmer']!;
355 expect(glimmer.supportsReasoning, isTrue);
356 expect(glimmer.reasoningMandatory, isTrue);
357 expect(glimmer.supportedReasoningEfforts, [
358 'low',
359 'medium',
360 'high',
361 'xhigh',
362 ]);
363 expect(glimmer.defaultReasoningEffort, 'medium');
364
365 final gemma = byId['google/gemma-4-e2b']!;
366 expect(gemma.supportsReasoning, isTrue);
367 expect(gemma.reasoningMandatory, isFalse);
368 expect(gemma.supportedReasoningEfforts, ['off', 'on']);
369
370 final r1 = byId['deepseek-r1']!;
371 expect(r1.supportsReasoning, isTrue);
372 expect(r1.reasoningMandatory, isTrue);
373
374 expect(byId['plain-model']!.supportsReasoning, isFalse);
375 expect(byId['plain-model']!.supportedReasoningEfforts, isNull);
376 });
377
378 test(
379 'handles running models with fallback data array (llama.cpp)',
380 () async {
381 final mockData = {
382 "object": "list",
383 "data": [
384 {"id": "active-model", "object": "model"},
385 ],
386 };
387
388 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
389 final service = ServerApiService(dio);
390
391 final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
392 final running = await service.fetchRunningModels(lmStudioServer);
393
394 expect(running, contains("active-model"));
395 },
396 );
397
398 test(
399 'handles running models with custom models array and loaded instances (LM Studio)',
400 () async {
401 final mockData = {
402 "models": [
403 {
404 "key": "active-model-lm",
405 "loaded_instances": [
406 {"id": "inst_1"},
407 ],
408 },
409 {"key": "inactive-model-lm", "loaded_instances": []},
410 ],
411 };
412
413 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
414 final service = ServerApiService(dio);
415
416 final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
417 final running = await service.fetchRunningModels(lmStudioServer);
418
419 expect(running, contains("inst_1"));
420 expect(running, isNot(contains("inactive-model-lm")));
421 },
422 );
423
424 test('handles custom string quantization value gracefully', () async {
425 final mockData = {
426 "data": [
427 {"id": "model-with-string-quant", "quantization": "Q5_K_M"},
428 ],
429 };
430
431 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
432 final service = ServerApiService(dio);
433
434 final models = await service.fetchModels(testServer);
435 expect(models.first.quantization, "Q5_K_M");
436 });
437
438 test(
439 'handles actual user llama.cpp endpoint output successfully with Map architecture',
440 () async {
441 final mockData = {
442 "data": [
443 {
444 "id": "Gemma-4-31B:IQ4_XS",
445 "aliases": [],
446 "tags": [],
447 "object": "model",
448 "owned_by": "llamacpp",
449 "created": 1779851034,
450 "status": {
451 "value": "unloaded",
452 "args": [
453 "D:\\llamacpp\\llama-server.exe",
454 "--host",
455 "127.0.0.1",
456 "--jinja",
457 "--mlock",
458 "--no-mmap",
459 "--port",
460 "0",
461 "--alias",
462 "Gemma-4-31B:IQ4_XS",
463 "--ctx-size",
464 "65536",
465 "--cache-type-k",
466 "q4_0",
467 "--cache-type-v",
468 "q4_0",
469 "--flash-attn",
470 "true",
471 "--model",
472 "./models/gemma-4-31B-it-llmfan-i1-IQ4_XS.gguf",
473 "--parallel",
474 "1",
475 "--reasoning",
476 "off",
477 ],
478 "preset":
479 "[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",
480 },
481 "architecture": {
482 "input_modalities": ["text"],
483 "output_modalities": ["text"],
484 },
485 },
486 ],
487 "object": "list",
488 };
489
490 final dio = Dio()..interceptors.add(TestInterceptor(mockData));
491 final service = ServerApiService(dio);
492
493 final models = await service.fetchModels(testServer);
494 expect(models, hasLength(1));
495 expect(models.first.id, "Gemma-4-31B:IQ4_XS");
496 expect(models.first.architecture, isNull);
497 },
498 );
499 });
500
501 group('ServerApiService - Ollama load behavior', () {
502 test(
503 'loadModel is a no-op for Ollama (does not POST /api/generate)',
504 () async {
505 final ollamaServer = Server(
506 id: 'test-ollama',
507 name: 'Test Ollama',
508 type: ServerType.ollama,
509 host: 'localhost',
510 port: 11434,
511 createdAt: DateTime.now(),
512 lastConnectedAt: DateTime.now(),
513 );
514
515 final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
516 final service = ServerApiService(dio);
517
518 await service.loadModel(ollamaServer, 'llama3.2:latest');
519 await service.loadModelWithInstanceId(ollamaServer, 'llama3.2:latest');
520
521 final recorder =
522 dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
523 as RequestRecordingInterceptor;
524 expect(
525 recorder.requests,
526 isEmpty,
527 reason:
528 'Ollama should not issue an explicit load request; '
529 'calling /api/generate without a prompt can trigger an '
530 'auto-pull of the model from the Ollama registry.',
531 );
532 },
533 );
534
535 test(
536 'unloadModel for Ollama posts keep_alive=0 and a valid payload',
537 () async {
538 final ollamaServer = Server(
539 id: 'test-ollama',
540 name: 'Test Ollama',
541 type: ServerType.ollama,
542 host: 'localhost',
543 port: 11434,
544 createdAt: DateTime.now(),
545 lastConnectedAt: DateTime.now(),
546 );
547
548 final dio = Dio()..interceptors.add(RequestRecordingInterceptor());
549 final service = ServerApiService(dio);
550
551 await service.unloadModel(ollamaServer, 'llama3.2:latest');
552
553 final recorder =
554 dio.interceptors.firstWhere((i) => i is RequestRecordingInterceptor)
555 as RequestRecordingInterceptor;
556 expect(recorder.requests, hasLength(1));
557 expect(recorder.requests.first.path, endsWith('/api/generate'));
558 expect(recorder.requests.first.data, containsPair('keep_alive', 0));
559 expect(
560 recorder.requests.first.data,
561 containsPair('model', 'llama3.2:latest'),
562 );
563 expect(recorder.requests.first.data, containsPair('prompt', ''));
564 },
565 );
566
567 test(
568 'fetchModels enriches Ollama models with vision capability from /api/show',
569 () async {
570 final ollamaServer = Server(
571 id: 'test-ollama',
572 name: 'Test Ollama',
573 type: ServerType.ollama,
574 host: 'localhost',
575 port: 11434,
576 createdAt: DateTime.now(),
577 lastConnectedAt: DateTime.now(),
578 );
579
580 final interceptor = RoutingInterceptor({
581 'GET /api/tags': {
582 'models': [
583 {
584 'name': 'qwen3-vl:2b',
585 'size': 1800000000,
586 'details': {
587 'parameter_size': '2.1B',
588 'quantization_level': 'Q4_K_M',
589 'family': 'qwen3',
590 },
591 },
592 {
593 'name': 'llama3.2:3b',
594 'size': 2000000000,
595 'details': {
596 'parameter_size': '3B',
597 'quantization_level': 'Q4_K_M',
598 'family': 'llama',
599 },
600 },
601 ],
602 },
603 'POST /api/show:qwen3-vl:2b': {
604 'capabilities': ['completion', 'vision'],
605 },
606 'POST /api/show:llama3.2:3b': {
607 'capabilities': ['completion'],
608 },
609 });
610
611 final service = ServerApiService(Dio()..interceptors.add(interceptor));
612 final models = await service.fetchModels(ollamaServer);
613
614 expect(models, hasLength(2));
615 final byId = {for (final m in models) m.id: m};
616 expect(byId['qwen3-vl:2b']?.supportsVision, isTrue);
617 expect(byId['llama3.2:3b']?.supportsVision, isFalse);
618 },
619 );
620
621 test('fetchModels sends canonical model key to Ollama /api/show', () async {
622 final ollamaServer = Server(
623 id: 'test-ollama',
624 name: 'Test Ollama',
625 type: ServerType.ollama,
626 host: 'localhost',
627 port: 11434,
628 createdAt: DateTime.now(),
629 lastConnectedAt: DateTime.now(),
630 );
631
632 final showBodies = <dynamic>[];
633 final dio = Dio()
634 ..interceptors.add(
635 InterceptorsWrapper(
636 onRequest: (options, handler) {
637 if (options.path.endsWith('/api/tags')) {
638 handler.resolve(
639 Response(
640 requestOptions: options,
641 statusCode: 200,
642 data: {
643 'models': [
644 {
645 'name': 'qwen3.5:9b',
646 'size': 6600000000,
647 'details': {
648 'parameter_size': '9.7B',
649 'quantization_level': 'Q4_K_M',
650 'family': 'qwen35',
651 },
652 },
653 ],
654 },
655 ),
656 );
657 return;
658 }
659 if (options.path.endsWith('/api/show')) {
660 showBodies.add(options.data);
661 handler.resolve(
662 Response(
663 requestOptions: options,
664 statusCode: 200,
665 data: {
666 'capabilities': ['completion', 'thinking'],
667 },
668 ),
669 );
670 return;
671 }
672 handler.next(options);
673 },
674 ),
675 );
676
677 final service = ServerApiService(dio);
678 final models = await service.fetchModels(ollamaServer);
679
680 expect(models, hasLength(1));
681 expect(showBodies, hasLength(1));
682 final showBody = showBodies.single;
683 expect(showBody, isA<Map>());
684 expect((showBody as Map).containsKey('model'), isTrue);
685 expect(showBody['model'], 'qwen3.5:9b');
686 expect(showBody.containsKey('name'), isFalse);
687 });
688
689 test(
690 'fetchModels marks Ollama thinking capability with binary off/on',
691 () async {
692 final ollamaServer = Server(
693 id: 'test-ollama',
694 name: 'Test Ollama',
695 type: ServerType.ollama,
696 host: 'localhost',
697 port: 11434,
698 createdAt: DateTime.now(),
699 lastConnectedAt: DateTime.now(),
700 );
701
702 final interceptor = RoutingInterceptor({
703 'GET /api/tags': {
704 'models': [
705 {
706 'name': 'qwen3.5:9b',
707 'size': 6600000000,
708 'details': {
709 'parameter_size': '9.7B',
710 'quantization_level': 'Q4_K_M',
711 'family': 'qwen35',
712 },
713 },
714 {
715 'name': 'llama3.2:3b',
716 'size': 2000000000,
717 'details': {
718 'parameter_size': '3B',
719 'quantization_level': 'Q4_K_M',
720 'family': 'llama',
721 },
722 },
723 ],
724 },
725 'POST /api/show:qwen3.5:9b': {
726 'capabilities': ['completion', 'tools', 'thinking'],
727 'details': {
728 'family': 'qwen35',
729 'families': ['qwen35'],
730 },
731 },
732 'POST /api/show:llama3.2:3b': {
733 'capabilities': ['completion'],
734 },
735 });
736
737 final service = ServerApiService(Dio()..interceptors.add(interceptor));
738 final models = await service.fetchModels(ollamaServer);
739
740 expect(models, hasLength(2));
741 final byId = {for (final m in models) m.id: m};
742 final thinker = byId['qwen3.5:9b']!;
743 expect(thinker.supportsReasoning, isTrue);
744 expect(thinker.supportedReasoningEfforts, ['off', 'on']);
745 expect(thinker.reasoningMandatory, isFalse);
746 expect(thinker.supportsToolUse, isTrue);
747
748 final plain = byId['llama3.2:3b']!;
749 expect(plain.supportsReasoning, isFalse);
750 expect(plain.supportedReasoningEfforts, isNull);
751 expect(plain.reasoningMandatory, isFalse);
752 },
753 );
754
755 test(
756 'fetchModels treats Ollama gptoss family as mandatory granular',
757 () async {
758 final ollamaServer = Server(
759 id: 'test-ollama',
760 name: 'Test Ollama',
761 type: ServerType.ollama,
762 host: 'localhost',
763 port: 11434,
764 createdAt: DateTime.now(),
765 lastConnectedAt: DateTime.now(),
766 );
767
768 final interceptor = RoutingInterceptor({
769 'GET /api/tags': {
770 'models': [
771 {
772 'name': 'gpt-oss:20b',
773 'size': 13700000000,
774 'details': {
775 'parameter_size': '20.9B',
776 'quantization_level': 'MXFP4',
777 'family': 'gptoss',
778 },
779 },
780 ],
781 },
782 'POST /api/show:gpt-oss:20b': {
783 'capabilities': ['completion'],
784 'details': {
785 'family': 'gptoss',
786 'families': ['gptoss'],
787 },
788 },
789 });
790
791 final service = ServerApiService(Dio()..interceptors.add(interceptor));
792 final models = await service.fetchModels(ollamaServer);
793
794 expect(models, hasLength(1));
795 final model = models.single;
796 expect(model.supportsReasoning, isTrue);
797 expect(model.supportedReasoningEfforts, ['low', 'medium', 'high']);
798 expect(model.reasoningMandatory, isTrue);
799 },
800 );
801
802 test(
803 'fetchModels treats the Ollama LFM2 thinking renderer as mandatory',
804 () async {
805 final ollamaServer = Server(
806 id: 'test-ollama',
807 name: 'Test Ollama',
808 type: ServerType.ollama,
809 host: 'localhost',
810 port: 11434,
811 createdAt: DateTime.now(),
812 lastConnectedAt: DateTime.now(),
813 );
814
815 final interceptor = RoutingInterceptor({
816 'GET /api/tags': {
817 'models': [
818 {
819 'name': 'lfm2.5-thinking:latest',
820 'size': 731163903,
821 'details': {
822 'parameter_size': '1.2B',
823 'quantization_level': 'Q4_K_M',
824 'family': 'lfm2',
825 },
826 },
827 ],
828 },
829 'POST /api/show:lfm2.5-thinking:latest': {
830 'capabilities': ['completion', 'tools', 'thinking'],
831 'details': {
832 'family': 'lfm2',
833 'families': ['lfm2'],
834 },
835 'modelfile':
836 'TEMPLATE {{ .Prompt }}\n'
837 'RENDERER lfm2-thinking\n'
838 'PARSER lfm2-thinking',
839 },
840 });
841
842 final service = ServerApiService(Dio()..interceptors.add(interceptor));
843 final model = (await service.fetchModels(ollamaServer)).single;
844
845 expect(model.supportsReasoning, isTrue);
846 expect(model.supportedReasoningEfforts, ['on']);
847 expect(model.reasoningMandatory, isTrue);
848 expect(model.supportsToolUse, isTrue);
849 },
850 );
851
852 test(
853 'fetchModels recognizes an older LFM2 thinking response by model id',
854 () async {
855 final ollamaServer = Server(
856 id: 'test-ollama',
857 name: 'Test Ollama',
858 type: ServerType.ollama,
859 host: 'localhost',
860 port: 11434,
861 createdAt: DateTime.now(),
862 lastConnectedAt: DateTime.now(),
863 );
864 final interceptor = RoutingInterceptor({
865 'GET /api/tags': {
866 'models': [
867 {
868 'name': 'lfm2.5-thinking:latest',
869 'details': {'family': 'lfm2'},
870 },
871 ],
872 },
873 'POST /api/show:lfm2.5-thinking:latest': {
874 'capabilities': ['completion'],
875 'details': {'family': 'lfm2'},
876 },
877 });
878
879 final service = ServerApiService(Dio()..interceptors.add(interceptor));
880 final model = (await service.fetchModels(ollamaServer)).single;
881
882 expect(model.supportedReasoningEfforts, ['on']);
883 expect(model.reasoningMandatory, isTrue);
884 },
885 );
886
887 test(
888 'fetchModels handles absent or malformed Ollama capabilities',
889 () async {
890 final ollamaServer = Server(
891 id: 'test-ollama',
892 name: 'Test Ollama',
893 type: ServerType.ollama,
894 host: 'localhost',
895 port: 11434,
896 createdAt: DateTime.now(),
897 lastConnectedAt: DateTime.now(),
898 );
899
900 final interceptor = RoutingInterceptor({
901 'GET /api/tags': {
902 'models': [
903 {'name': 'missing-caps:latest', 'size': 1000, 'details': {}},
904 {'name': 'malformed-caps:latest', 'size': 1000, 'details': {}},
905 {'name': 'failing-caps:latest', 'size': 1000, 'details': {}},
906 ],
907 },
908 'POST /api/show:missing-caps:latest': {'details': {}},
909 'POST /api/show:malformed-caps:latest': {'capabilities': 'thinking'},
910 // No mock for failing-caps:latest — enrichment must fall back to the
911 // base model instead of failing the whole fetch.
912 });
913
914 final service = ServerApiService(Dio()..interceptors.add(interceptor));
915 final models = await service.fetchModels(ollamaServer);
916
917 expect(models, hasLength(3));
918 for (final model in models) {
919 expect(model.supportsReasoning, isFalse);
920 expect(model.supportedReasoningEfforts, isNull);
921 expect(model.reasoningMandatory, isFalse);
922 }
923 },
924 );
925
926 test('unloadModel for Ollama surfaces failures', () async {
927 final ollamaServer = Server(
928 id: 'test-ollama',
929 name: 'Test Ollama',
930 type: ServerType.ollama,
931 host: 'localhost',
932 port: 11434,
933 createdAt: DateTime.now(),
934 lastConnectedAt: DateTime.now(),
935 );
936
937 final dio = Dio()..interceptors.add(FailingInterceptor());
938 final service = ServerApiService(dio);
939
940 expect(
941 () => service.unloadModel(ollamaServer, 'llama3.2:latest'),
942 throwsA(isA<Exception>()),
943 );
944 });
945 });
946
947 group('ServerApiService - OpenRouter model parsing', () {
948 late Server openRouterServer;
949
950 setUp(() {
951 openRouterServer = Server(
952 id: 'test-openrouter',
953 name: 'Test OpenRouter',
954 type: ServerType.openRouter,
955 host: 'openrouter.ai',
956 port: 443,
957 apiKey: 'test-key',
958 createdAt: DateTime.now(),
959 lastConnectedAt: DateTime.now(),
960 );
961 });
962
963 test(
964 'parses capabilities and per-1M pricing for OpenRouter models',
965 () async {
966 final data = {
967 'data': [
968 {
969 'id': 'openai/gpt-4o',
970 'name': 'OpenAI: GPT-4o',
971 'created': 1715558400,
972 'context_length': 128000,
973 'architecture': {
974 'modality': 'text+image+file->text',
975 'input_modalities': ['text', 'image', 'file'],
976 'output_modalities': ['text'],
977 'tokenizer': 'GPT',
978 'instruct_type': null,
979 },
980 'pricing': {'prompt': '0.0000025', 'completion': '0.00001'},
981 },
982 {
983 'id': 'deepseek/deepseek-reasoner',
984 'name': 'DeepSeek: R1',
985 'context_length': 163840,
986 'architecture': {
987 'modality': 'text->text',
988 'input_modalities': ['text'],
989 'output_modalities': ['text'],
990 'tokenizer': 'DeepSeek',
991 },
992 'reasoning': {
993 'mandatory': false,
994 'default_effort': 'high',
995 'supported_efforts': ['max', 'high', 'low'],
996 },
997 'supported_parameters': ['tools', 'reasoning', 'temperature'],
998 },
999 {
1000 'id': 'meta-llama/llama-3.1-8b-instruct:free',
1001 'name': 'Meta: Llama 3.1 8B (Free)',
1002 'context_length': 131072,
1003 'architecture': {
1004 'modality': 'text->text',
1005 'input_modalities': ['text'],
1006 'output_modalities': ['text'],
1007 'tokenizer': 'Llama3',
1008 },
1009 'pricing': {'prompt': '0', 'completion': '0'},
1010 },
1011 ],
1012 };
1013
1014 final service = ServerApiService(
1015 Dio()..interceptors.add(TestInterceptor(data)),
1016 );
1017 final models = await service.fetchModels(openRouterServer);
1018
1019 expect(models, hasLength(3));
1020 final byId = {for (final m in models) m.id: m};
1021
1022 final gpt4o = byId['openai/gpt-4o']!;
1023 expect(gpt4o.supportsVision, isTrue);
1024 expect(gpt4o.inputPricePerMillion, closeTo(2.5, 0.0001));
1025 expect(gpt4o.outputPricePerMillion, closeTo(10.0, 0.0001));
1026 expect(gpt4o.pricingLabel, '\$2.50/\$10.00');
1027
1028 final reasoner = byId['deepseek/deepseek-reasoner']!;
1029 expect(reasoner.supportsReasoning, isTrue);
1030 expect(reasoner.supportsToolUse, isTrue);
1031 expect(reasoner.supportsVision, isFalse);
1032 expect(reasoner.reasoningMandatory, isFalse);
1033 expect(reasoner.defaultReasoningEffort, 'high');
1034 expect(reasoner.supportedReasoningEfforts, ['max', 'high', 'low']);
1035
1036 final free = byId['meta-llama/llama-3.1-8b-instruct:free']!;
1037 expect(free.isPricingFree, isTrue);
1038 expect(free.pricingLabel, 'Free');
1039 },
1040 );
1041
1042 test(
1043 'detects vision via modality when input_modalities is absent',
1044 () async {
1045 final data = {
1046 'data': [
1047 {
1048 'id': 'google/gemini-pro',
1049 'name': 'Google: Gemini Pro',
1050 'context_length': 32768,
1051 'architecture': {
1052 'modality': 'text+image->text',
1053 'tokenizer': 'Gemini',
1054 },
1055 },
1056 ],
1057 };
1058
1059 final service = ServerApiService(
1060 Dio()..interceptors.add(TestInterceptor(data)),
1061 );
1062 final models = await service.fetchModels(openRouterServer);
1063
1064 expect(models, hasLength(1));
1065 expect(models.single.supportsVision, isTrue);
1066 expect(models.single.supportsReasoning, isFalse);
1067 expect(models.single.inputPricePerMillion, isNull);
1068 expect(models.single.pricingLabel, isNull);
1069 },
1070 );
1071
1072 test('parses mandatory reasoning with a single supported effort', () async {
1073 final data = {
1074 'data': [
1075 {
1076 'id': 'openai/gpt-5-pro',
1077 'name': 'OpenAI: GPT-5 Pro',
1078 'context_length': 200000,
1079 'architecture': {'modality': 'text->text', 'tokenizer': 'GPT'},
1080 'reasoning': {
1081 'mandatory': true,
1082 'default_effort': 'high',
1083 'supported_efforts': ['high'],
1084 },
1085 },
1086 ],
1087 };
1088
1089 final service = ServerApiService(
1090 Dio()..interceptors.add(TestInterceptor(data)),
1091 );
1092 final models = await service.fetchModels(openRouterServer);
1093
1094 expect(models, hasLength(1));
1095 final model = models.single;
1096 expect(model.supportsReasoning, isTrue);
1097 expect(model.reasoningMandatory, isTrue);
1098 expect(model.supportedReasoningEfforts, ['high']);
1099 expect(model.defaultReasoningEffort, 'high');
1100 });
1101
1102 test('treats non-finite pricing values as unknown', () async {
1103 final data = {
1104 'data': [
1105 {
1106 'id': 'weird/model',
1107 'name': 'Weird Model',
1108 'context_length': 8192,
1109 'architecture': {'modality': 'text->text', 'tokenizer': 'X'},
1110 'pricing': {'prompt': '1e309', 'completion': 'Infinity'},
1111 },
1112 ],
1113 };
1114
1115 final service = ServerApiService(
1116 Dio()..interceptors.add(TestInterceptor(data)),
1117 );
1118 final models = await service.fetchModels(openRouterServer);
1119
1120 expect(models, hasLength(1));
1121 expect(models.single.inputPricePerMillion, isNull);
1122 expect(models.single.outputPricePerMillion, isNull);
1123 expect(models.single.pricingLabel, isNull);
1124 });
1125 });
1126
1127 group('ServerApiService - Requesty model parsing', () {
1128 late Server requestyServer;
1129
1130 setUp(() {
1131 requestyServer = Server(
1132 id: 'test-requesty',
1133 name: 'Test Requesty',
1134 type: ServerType.requesty,
1135 host: '',
1136 port: 443,
1137 apiKey: 'test-key',
1138 createdAt: DateTime.now(),
1139 lastConnectedAt: DateTime.now(),
1140 );
1141 });
1142
1143 test('uses the Requesty router endpoints', () {
1144 expect(requestyServer.baseUrl, 'https://router.requesty.ai/v1');
1145 expect(
1146 requestyServer.chatEndpoint,
1147 'https://router.requesty.ai/v1/chat/completions',
1148 );
1149 expect(
1150 requestyServer.modelsEndpoint,
1151 'https://router.requesty.ai/v1/models',
1152 );
1153 expect(requestyServer.displayAddress, 'router.requesty.ai');
1154 });
1155
1156 test(
1157 'lists managed policies first, then the catalog without duplicates',
1158 () async {
1159 final service = ServerApiService(
1160 Dio()
1161 ..interceptors.add(
1162 RoutingInterceptor({
1163 'GET https://router.requesty.ai/v1/models': {
1164 'object': 'list',
1165 'data': [
1166 {
1167 'id': 'openai/gpt-4o-mini',
1168 'api': 'chat',
1169 'context_window': 128000,
1170 'input_price': 1.5e-07,
1171 'output_price': 6e-07,
1172 'supports_vision': true,
1173 'supports_tool_calling': true,
1174 'supports_reasoning': false,
1175 },
1176 {
1177 'id': 'claude-sonnet-4-5',
1178 'api': 'chat',
1179 'context_window': 200000,
1180 },
1181 {'id': 'openai/text-embedding-3-small', 'api': 'embedding'},
1182 ],
1183 },
1184 'GET https://router.requesty.ai/v1/models/managed': {
1185 'object': 'list',
1186 'data': [
1187 {
1188 'id': 'claude-sonnet-4-5',
1189 'name': 'Claude Sonnet 4.5',
1190 'api': 'chat',
1191 'context_window': 1000000,
1192 'input_price': 3e-06,
1193 'output_price': 1.5e-05,
1194 'supports_reasoning': true,
1195 'supports_tool_calling': true,
1196 },
1197 ],
1198 },
1199 }),
1200 ),
1201 );
1202 final models = await service.fetchModels(requestyServer);
1203
1204 expect(models.map((m) => m.id), [
1205 'claude-sonnet-4-5',
1206 'openai/gpt-4o-mini',
1207 ]);
1208
1209 final managed = models.first;
1210 expect(managed.name, 'Claude Sonnet 4.5');
1211 expect(managed.contextLength, 1000000);
1212 expect(managed.supportsReasoning, isTrue);
1213 expect(managed.supportsToolUse, isTrue);
1214 expect(managed.inputPricePerMillion, closeTo(3.0, 0.0001));
1215 expect(managed.outputPricePerMillion, closeTo(15.0, 0.0001));
1216
1217 final mini = models.last;
1218 expect(mini.name, 'openai/gpt-4o-mini');
1219 expect(mini.serverType, ServerType.requesty);
1220 expect(mini.contextLength, 128000);
1221 expect(mini.supportsVision, isTrue);
1222 expect(mini.supportsReasoning, isFalse);
1223 expect(mini.pricingLabel, '\$0.15/\$0.60');
1224 },
1225 );
1226
1227 test('falls back to the catalog when managed models fail', () async {
1228 final service = ServerApiService(
1229 Dio()
1230 ..interceptors.add(
1231 RoutingInterceptor({
1232 'GET https://router.requesty.ai/v1/models': {
1233 'object': 'list',
1234 'data': [
1235 {'id': 'openai/gpt-4o-mini', 'api': 'chat'},
1236 ],
1237 },
1238 }),
1239 ),
1240 );
1241 final models = await service.fetchModels(requestyServer);
1242
1243 expect(models.map((m) => m.id), ['openai/gpt-4o-mini']);
1244 });
1245 });
1246
1247 group('ServerApiService - LM Studio load model behavior', () {
1248 late Server lmStudioServer;
1249
1250 setUp(() {
1251 lmStudioServer = Server(
1252 id: 'test-lmstudio',
1253 name: 'Test LM Studio',
1254 type: ServerType.lmStudio,
1255 host: 'localhost',
1256 port: 1234,
1257 createdAt: DateTime.now(),
1258 lastConnectedAt: DateTime.now(),
1259 );
1260 });
1261
1262 test(
1263 'loadModelWithInstanceId succeeds with LM Studio response having "type": "llm"',
1264 () async {
1265 final successData = {
1266 'type': 'llm',
1267 'instance_id': 'liquid/lfm2-1.2b',
1268 'load_config': {'context_length': 4096},
1269 };
1270
1271 final dio = Dio()..interceptors.add(TestInterceptor(successData));
1272 final service = ServerApiService(dio);
1273
1274 final instanceId = await service.loadModelWithInstanceId(
1275 lmStudioServer,
1276 'liquid/lfm2-1.2b',
1277 contextLength: 4096,
1278 );
1279
1280 expect(instanceId, 'liquid/lfm2-1.2b');
1281 },
1282 );
1283
1284 test(
1285 'loadModelWithInstanceId throws when response contains error object',
1286 () async {
1287 final errorData = {
1288 'error': {
1289 'type': 'invalid_request_error',
1290 'message': 'Context length exceeds model limit',
1291 },
1292 };
1293
1294 final dio = Dio()..interceptors.add(TestInterceptor(errorData));
1295 final service = ServerApiService(dio);
1296
1297 expect(
1298 () => service.loadModelWithInstanceId(
1299 lmStudioServer,
1300 'liquid/lfm2-1.2b',
1301 contextLength: 999999,
1302 ),
1303 throwsA(
1304 predicate(
1305 (e) =>
1306 e is Exception &&
1307 e.toString().contains(
1308 'invalid_request_error: Context length exceeds model limit',
1309 ),
1310 ),
1311 ),
1312 );
1313 },
1314 );
1315
1316 test(
1317 'loadModelWithInstanceId throws when response contains status error',
1318 () async {
1319 final errorData = {
1320 'status': 'error',
1321 'message': 'Failed to allocate memory',
1322 };
1323
1324 final dio = Dio()..interceptors.add(TestInterceptor(errorData));
1325 final service = ServerApiService(dio);
1326
1327 expect(
1328 () => service.loadModelWithInstanceId(
1329 lmStudioServer,
1330 'liquid/lfm2-1.2b',
1331 ),
1332 throwsA(
1333 predicate(
1334 (e) =>
1335 e is Exception &&
1336 e.toString().contains('Failed to allocate memory'),
1337 ),
1338 ),
1339 );
1340 },
1341 );
1342 });
1343 }