main
dart 1,050 lines 35 KB
Raw
1 import 'dart:convert';
2 import 'package:dio/dio.dart';
3
4 import '../../../core/logger/app_logger.dart';
5 import '../../../core/models/enums.dart';
6 import '../../models/data/models/model_info.dart';
7 import 'models/server.dart';
8
9 class ServerApiService {
10 final Dio _dio;
11
12 ServerApiService(this._dio);
13
14 Future<bool> testConnection(Server server) async {
15 if (server.isOnDevice) {
16 return true;
17 }
18
19 final endpointsToTry = <String>[];
20 if (server.modelsEndpoint.isNotEmpty) {
21 endpointsToTry.add(server.modelsEndpoint);
22 }
23
24 final prefix = server.apiPathPrefix;
25 if (server.type == ServerType.lmStudio) {
26 endpointsToTry.add('${server.baseUrl}$prefix/v1/models');
27 endpointsToTry.add('${server.baseUrl}$prefix/api/v0/models');
28 } else if (server.type == ServerType.openAICompatible) {
29 endpointsToTry.add('${server.baseUrl}$prefix/models');
30 } else if (server.type == ServerType.ollama) {
31 endpointsToTry.add('${server.baseUrl}$prefix/api/tags');
32 endpointsToTry.add('${server.baseUrl}$prefix/api/version');
33 }
34
35 for (final endpoint in endpointsToTry.toSet()) {
36 if (endpoint.isEmpty) continue;
37 try {
38 final response = await _dio.get(
39 endpoint,
40 options: Options(
41 headers: buildServerAuthHeaders(server),
42 validateStatus: (status) => status != null && status < 500,
43 ),
44 );
45 if (response.statusCode == 200) {
46 return true;
47 }
48 } catch (_) {
49 // Try next endpoint
50 }
51 }
52 return false;
53 }
54
55 Future<int?> pingServer(Server server) async {
56 final stopwatch = Stopwatch()..start();
57 try {
58 await _dio.head(
59 server.baseUrl,
60 options: Options(headers: buildServerAuthHeaders(server)),
61 );
62 stopwatch.stop();
63 return stopwatch.elapsedMilliseconds;
64 } catch (e) {
65 return null;
66 }
67 }
68
69 Future<List<ModelInfo>> fetchModels(Server server) async {
70 if (server.isOnDevice) {
71 return [];
72 }
73 try {
74 Response response;
75 try {
76 response = await _dio.get(
77 server.modelsEndpoint,
78 options: Options(headers: buildServerAuthHeaders(server)),
79 );
80 } on DioException catch (e) {
81 if (e.response?.statusCode == 404 &&
82 server.type == ServerType.lmStudio) {
83 response = await _dio.get(
84 '${server.baseUrl}${server.apiPathPrefix}/v1/models',
85 options: Options(headers: buildServerAuthHeaders(server)),
86 );
87 } else {
88 rethrow;
89 }
90 }
91
92 List<ModelInfo> models;
93 switch (server.type) {
94 case ServerType.lmStudio:
95 case ServerType.openAICompatible:
96 models = _parseOpenAICompatibleModels(response.data, server);
97 break;
98 case ServerType.ollama:
99 case ServerType.ollamaCloud:
100 models = await _parseAndEnrichOllamaModels(response.data, server);
101 break;
102 case ServerType.openRouter:
103 models = _parseOpenRouterModels(response.data, server);
104 break;
105 case ServerType.requesty:
106 models = await _fetchRequestyModels(response.data, server);
107 break;
108 case ServerType.onDevice:
109 models = [];
110 break;
111 }
112 return models;
113 } catch (e) {
114 if (e is DioException) {
115 final apiMsg = _extractApiErrorMessage(e.response?.data);
116 if (apiMsg != null) {
117 throw Exception('Failed to fetch models: $apiMsg');
118 }
119 }
120 throw Exception('Failed to fetch models: $e');
121 }
122 }
123
124 Future<Set<String>> fetchRunningModels(Server server) async {
125 if (server.type == ServerType.openRouter ||
126 server.type == ServerType.requesty ||
127 server.type == ServerType.openAICompatible ||
128 server.isOnDevice) {
129 return {};
130 }
131
132 try {
133 final response = await _dio.get(
134 server.runningModelsEndpoint,
135 options: Options(headers: buildServerAuthHeaders(server)),
136 );
137
138 if (server.type == ServerType.lmStudio) {
139 return _parseRunningOpenAICompatibleModels(response.data);
140 }
141 return _parseRunningOllamaModels(response.data);
142 } catch (e) {
143 return {};
144 }
145 }
146
147 Future<void> loadModel(Server server, String modelId) async {
148 if (server.type == ServerType.openRouter ||
149 server.type == ServerType.requesty ||
150 server.isOnDevice) {
151 return;
152 }
153
154 switch (server.type) {
155 case ServerType.lmStudio:
156 case ServerType.openAICompatible:
157 try {
158 await _dio.post(
159 server.loadModelEndpoint,
160 data: {'model': modelId},
161 options: Options(headers: buildServerAuthHeaders(server)),
162 );
163 } catch (_) {
164 // Not all OpenAI-compatible servers support this endpoint
165 }
166 break;
167 case ServerType.ollama:
168 case ServerType.ollamaCloud:
169 // Ollama (and Ollama Cloud) auto-loads models on /api/chat. Calling
170 // /api/generate here would trigger an auto-pull (download) when the
171 // model is not already present on the server, which is not what the
172 // user wants.
173 return;
174 case ServerType.openRouter:
175 case ServerType.requesty:
176 case ServerType.onDevice:
177 break;
178 }
179 }
180
181 Future<String?> loadModelWithInstanceId(
182 Server server,
183 String modelId, {
184 int? contextLength,
185 }) async {
186 if (server.type == ServerType.openRouter ||
187 server.type == ServerType.requesty ||
188 server.isOnDevice) {
189 return null;
190 }
191
192 switch (server.type) {
193 case ServerType.lmStudio:
194 case ServerType.openAICompatible:
195 try {
196 final payload = <String, dynamic>{
197 'model': modelId,
198 'echo_load_config': true,
199 };
200 if (contextLength != null) {
201 payload['context_length'] = contextLength;
202 }
203 final response = await _dio.post(
204 server.loadModelEndpoint,
205 data: payload,
206 options: Options(headers: buildServerAuthHeaders(server)),
207 );
208 _throwIfErrorResponse(response.data);
209 final data = response.data;
210 dynamic map = data;
211 if (data is String && data.trim().startsWith('{')) {
212 try {
213 map = jsonDecode(data);
214 } catch (_) {}
215 }
216 if (map is Map) {
217 return map['instance_id']?.toString() ??
218 map['id']?.toString() ??
219 map['model']?.toString() ??
220 modelId;
221 }
222 return modelId;
223 } on DioException catch (e) {
224 throw Exception(
225 _extractApiErrorMessage(e.response?.data) ?? e.message,
226 );
227 }
228 case ServerType.ollama:
229 case ServerType.ollamaCloud:
230 // Ollama (and Ollama Cloud) auto-loads models on /api/chat. Calling
231 // /api/generate here would trigger an auto-pull (download) when the
232 // model is not already present on the server, which is not what the
233 // user wants.
234 return null;
235 case ServerType.openRouter:
236 case ServerType.requesty:
237 case ServerType.onDevice:
238 return null;
239 }
240 }
241
242 Future<void> unloadAllInstances(
243 Server server,
244 Set<String> instanceIds,
245 ) async {
246 for (final instanceId in instanceIds) {
247 await unloadModel(server, instanceId, instanceId: instanceId);
248 }
249 }
250
251 Future<void> unloadInstancesForModelKey(
252 Server server,
253 String modelKey,
254 Set<String> instanceIds,
255 ) async {
256 final targets = instanceIds
257 .where((id) => id == modelKey || id.startsWith('$modelKey:'))
258 .toList();
259 for (final instanceId in targets) {
260 await unloadModel(server, modelKey, instanceId: instanceId);
261 }
262 }
263
264 Future<void> unloadModel(
265 Server server,
266 String modelId, {
267 String? instanceId,
268 }) async {
269 if (server.type == ServerType.openRouter ||
270 server.type == ServerType.requesty ||
271 server.isOnDevice) {
272 return;
273 }
274
275 switch (server.type) {
276 case ServerType.lmStudio:
277 case ServerType.openAICompatible:
278 try {
279 await _dio.post(
280 server.unloadModelEndpoint,
281 data: {'instance_id': instanceId ?? modelId},
282 options: Options(headers: buildServerAuthHeaders(server)),
283 );
284 } catch (_) {
285 // Not all OpenAI-compatible servers support this endpoint
286 }
287 break;
288 case ServerType.ollama:
289 await _dio.post(
290 server.unloadModelEndpoint,
291 data: {'model': modelId, 'keep_alive': 0, 'prompt': ''},
292 options: Options(headers: buildServerAuthHeaders(server)),
293 );
294 break;
295 case ServerType.ollamaCloud:
296 // Ollama Cloud manages model lifecycle server-side; no client-side
297 // unload action is supported.
298 return;
299 case ServerType.openRouter:
300 case ServerType.requesty:
301 case ServerType.onDevice:
302 break;
303 }
304 }
305
306 /// Returns the context length the currently loaded model was actually
307 /// loaded with — verified live against LM Studio's `/api/v0/models/{id}`,
308 /// which reports `loaded_context_length` (e.g. 15000) separately from
309 /// `max_context_length` (the model's architectural maximum, e.g. 131072).
310 /// The loaded value can be much smaller than the max if the user picked a
311 /// smaller context window at load time, so it's what "percent of context
312 /// used" should be measured against. Falls back to `max_context_length`
313 /// if the model isn't reported as loaded. Only supported for LM Studio;
314 /// other server types return null so callers can fall back to their own
315 /// configured context length.
316 Future<int?> fetchLoadedContextLength(Server server, String modelId) async {
317 if (server.type != ServerType.lmStudio) return null;
318
319 try {
320 final response = await _dio.get(
321 '${server.baseUrl}/api/v0/models/${Uri.encodeComponent(modelId)}',
322 options: Options(
323 headers: buildServerAuthHeaders(server),
324 validateStatus: (status) => status != null && status < 500,
325 ),
326 );
327 final data = response.data;
328 if (data is! Map) return null;
329 return _toInt(data['loaded_context_length']) ??
330 _toInt(data['max_context_length']);
331 } catch (e) {
332 Log.warning('Failed to fetch loaded context length: $e');
333 return null;
334 }
335 }
336
337 Set<String> _parseRunningOpenAICompatibleModels(dynamic data) {
338 final runningModels = <String>{};
339 if (data == null) return runningModels;
340 final modelItems = data['models'] ?? data['data'];
341 if (modelItems is List) {
342 for (final item in modelItems) {
343 if (item is! Map) continue;
344 final id =
345 item['key']?.toString() ??
346 item['name']?.toString() ??
347 item['id']?.toString() ??
348 '';
349 if (id.isEmpty) continue;
350
351 final loadedInstances = item['loaded_instances'];
352 if (loadedInstances is List) {
353 for (final instance in loadedInstances) {
354 if (instance is! Map) continue;
355 final instanceId = instance['id']?.toString();
356 if (instanceId != null && instanceId.isNotEmpty) {
357 runningModels.add(instanceId);
358 }
359 }
360 } else {
361 // Servers without loaded_instances (e.g. llama.cpp) always have
362 // their model running if it appears in the model list
363 runningModels.add(id);
364 }
365 }
366 }
367 return runningModels;
368 }
369
370 Set<String> _parseRunningOllamaModels(dynamic data) {
371 final runningModels = <String>{};
372 final models = data['models'];
373 if (models is List) {
374 for (final item in models) {
375 if (item is! Map) continue;
376 final name = item['name'];
377 if (name != null) runningModels.add(name.toString());
378 }
379 }
380 return runningModels;
381 }
382
383 bool _isOpenAICompatibleReasoningModel(String modelId) {
384 final lower = modelId.trim().toLowerCase();
385 if (RegExp(r'(^|[/:\-_])o[1-9]([-_/:.]|$)').hasMatch(lower)) {
386 return true;
387 }
388 return RegExp(r'(^|[/_\-])gpt-[5-9]([._:/\-]|$)').hasMatch(lower);
389 }
390
391 List<ModelInfo> _parseOpenAICompatibleModels(dynamic data, Server server) {
392 final List<ModelInfo> models = [];
393 if (data == null) return models;
394
395 // Build a lookup from the OpenAI-style 'data' array for metadata
396 // that may be missing from the 'models' array (e.g. llama.cpp)
397 final Map<String, Map<String, dynamic>> metaById = {};
398 if (data['data'] is List) {
399 for (final item in data['data']) {
400 if (item is Map) {
401 final id = item['id']?.toString() ?? '';
402 if (id.isNotEmpty) {
403 metaById[id] = (item['meta'] as Map<String, dynamic>?) ?? {};
404 }
405 }
406 }
407 }
408
409 final modelItems = data['models'] ?? data['data'];
410 if (modelItems != null && modelItems is List) {
411 for (final item in modelItems) {
412 if (item is! Map) continue;
413 final id =
414 item['key']?.toString() ??
415 item['name']?.toString() ??
416 item['id']?.toString() ??
417 '';
418 if (id.isEmpty) continue;
419
420 final displayName = item['display_name']?.toString();
421 final quantization = item['quantization'];
422 final paramsString = item['params_string']?.toString();
423
424 final meta = metaById[id] ?? (item['meta'] as Map<String, dynamic>?);
425 if (meta == null && metaById.isNotEmpty && data['models'] != null) {
426 Log.warning('Metadata join failed for model: $id');
427 }
428 final finalMeta = meta ?? {};
429
430 String? quantName;
431 if (quantization is Map) {
432 quantName = quantization['name']?.toString();
433 } else if (quantization is String) {
434 quantName = quantization;
435 }
436
437 final archValue = item['architecture'];
438 String? archName;
439 if (archValue is String) {
440 archName = archValue;
441 }
442
443 final rawCapabilities = item['capabilities'];
444 final capabilities = _parseModelCapabilities(rawCapabilities);
445 final isReasoning =
446 capabilities.supportsReasoning ||
447 (rawCapabilities == null &&
448 server.type == ServerType.openAICompatible &&
449 _isOpenAICompatibleReasoningModel(id));
450 final supportedReasoningEfforts = capabilities.supportsReasoning
451 ? capabilities.supportedReasoningEfforts
452 : (isReasoning ? const ['low', 'medium', 'high'] : null);
453 final defaultReasoningEffort = capabilities.supportsReasoning
454 ? capabilities.defaultReasoningEffort
455 : (isReasoning ? 'medium' : null);
456 final reasoningMandatory = capabilities.supportsReasoning
457 ? capabilities.reasoningMandatory
458 : false;
459
460 models.add(
461 ModelInfo(
462 id: id,
463 name: displayName ?? _formatModelName(id),
464 description: item['description']?.toString(),
465 parameterCount:
466 _parseParameterString(paramsString) ??
467 _paramCountFromMeta(finalMeta['n_params']),
468 contextLength:
469 _toInt(item['max_context_length']) ??
470 _toInt(finalMeta['n_ctx_train']),
471 fileSize: _toInt(item['size_bytes']) ?? _toInt(finalMeta['size']),
472 quantization: quantName,
473 architecture: archName,
474 serverType: server.type,
475 serverId: server.id,
476 supportsVision: capabilities.supportsVision,
477 supportsReasoning: isReasoning,
478 supportsToolUse: capabilities.supportsToolUse,
479 supportedReasoningEfforts: supportedReasoningEfforts,
480 defaultReasoningEffort: defaultReasoningEffort,
481 reasoningMandatory: reasoningMandatory,
482 ),
483 );
484 }
485 }
486 return models;
487 }
488
489 int? _toInt(dynamic value) {
490 if (value == null) return null;
491 if (value is int) return value;
492 if (value is double) return value.toInt();
493 if (value is String) return int.tryParse(value);
494 return null;
495 }
496
497 ({
498 bool supportsVision,
499 bool supportsReasoning,
500 bool supportsToolUse,
501 List<String>? supportedReasoningEfforts,
502 String? defaultReasoningEffort,
503 bool reasoningMandatory,
504 })
505 _parseModelCapabilities(dynamic raw) {
506 if (raw is! Map) {
507 return (
508 supportsVision: false,
509 supportsReasoning: false,
510 supportsToolUse: false,
511 supportedReasoningEfforts: null,
512 defaultReasoningEffort: null,
513 reasoningMandatory: false,
514 );
515 }
516
517 final reasoning = raw['reasoning'];
518 final supportsVision = raw['vision'] == true;
519 final supportsToolUse = raw['trained_for_tool_use'] == true;
520 if (reasoning is! Map || reasoning.isEmpty) {
521 return (
522 supportsVision: supportsVision,
523 supportsReasoning: false,
524 supportsToolUse: supportsToolUse,
525 supportedReasoningEfforts: null,
526 defaultReasoningEffort: null,
527 reasoningMandatory: false,
528 );
529 }
530
531 // LM Studio native shape: `reasoning: {allowed_options: [...],
532 // default: ...}`. Examples: ["off","on"], ["on"] (mandatory),
533 // ["low","medium","high","xhigh"] (e.g. meta/muse-glimmer, mandatory
534 // because "off" is absent). Preserve the raw list (lower-cased) so the
535 // chat layer can send exactly what the server advertises instead of a
536 // generic "on"/"off" it may reject with HTTP 400.
537 final allowedRaw = reasoning['allowed_options'];
538 List<String>? allowedOptions;
539 if (allowedRaw is List) {
540 final parsed = allowedRaw
541 .map((e) => e.toString().trim().toLowerCase())
542 .where((e) => e.isNotEmpty)
543 .toList(growable: false);
544 if (parsed.isNotEmpty) allowedOptions = parsed;
545 }
546 final defaultOption = reasoning['default']?.toString().trim().toLowerCase();
547
548 final supportsReasoning = allowedOptions == null
549 // Legacy map without allowed_options (or unexpected shape):
550 // keep the old "non-empty means supported" behaviour.
551 ? true
552 // ["off"] alone means no reasoning to enable.
553 : allowedOptions.any((o) => o != 'off');
554
555 final reasoningMandatory =
556 supportsReasoning &&
557 allowedOptions != null &&
558 !allowedOptions.contains('off');
559
560 if (!supportsReasoning) {
561 return (
562 supportsVision: supportsVision,
563 supportsReasoning: false,
564 supportsToolUse: supportsToolUse,
565 supportedReasoningEfforts: null,
566 defaultReasoningEffort: null,
567 reasoningMandatory: false,
568 );
569 }
570
571 return (
572 supportsVision: supportsVision,
573 supportsReasoning: supportsReasoning,
574 supportsToolUse: supportsToolUse,
575 supportedReasoningEfforts: allowedOptions,
576 defaultReasoningEffort:
577 (defaultOption != null && defaultOption.isNotEmpty)
578 ? defaultOption
579 : null,
580 reasoningMandatory: reasoningMandatory,
581 );
582 }
583
584 double? _paramCountFromMeta(dynamic nParams) {
585 if (nParams == null) return null;
586 if (nParams is int) return nParams / 1000000000;
587 if (nParams is double) return nParams / 1000000000;
588 if (nParams is String) {
589 final parsedDouble = double.tryParse(nParams);
590 if (parsedDouble != null) return parsedDouble / 1000000000;
591 }
592 return null;
593 }
594
595 Future<List<ModelInfo>> _parseAndEnrichOllamaModels(
596 dynamic data,
597 Server server,
598 ) async {
599 final models = _parseOllamaModels(data, server);
600 if (models.isEmpty) return models;
601
602 return Future.wait(
603 models.map((model) async {
604 try {
605 final response = await _dio.post(
606 server.modelDetailsEndpoint,
607 data: {'model': model.id},
608 options: Options(headers: buildServerAuthHeaders(server)),
609 );
610 final capabilities = _parseOllamaCapabilityList(
611 response.data,
612 modelId: model.id,
613 );
614 return model.copyWith(
615 supportsVision: capabilities.supportsVision,
616 supportsReasoning: capabilities.supportsReasoning,
617 supportsToolUse: capabilities.supportsToolUse,
618 supportedReasoningEfforts: capabilities.supportedReasoningEfforts,
619 defaultReasoningEffort: capabilities.defaultReasoningEffort,
620 reasoningMandatory: capabilities.reasoningMandatory,
621 );
622 } catch (error) {
623 Log.warning(
624 'Unable to read Ollama capabilities for ${model.id}: $error',
625 );
626 return model;
627 }
628 }),
629 );
630 }
631
632 ({
633 bool supportsVision,
634 bool supportsReasoning,
635 bool supportsToolUse,
636 List<String>? supportedReasoningEfforts,
637 String? defaultReasoningEffort,
638 bool reasoningMandatory,
639 })
640 _parseOllamaCapabilityList(dynamic data, {required String modelId}) {
641 if (data is! Map) {
642 return (
643 supportsVision: false,
644 supportsReasoning: false,
645 supportsToolUse: false,
646 supportedReasoningEfforts: null,
647 defaultReasoningEffort: null,
648 reasoningMandatory: false,
649 );
650 }
651 final rawCapabilities = data['capabilities'];
652 final capabilities = rawCapabilities is List
653 ? rawCapabilities
654 .map((value) => value.toString().trim().toLowerCase())
655 .toSet()
656 : <String>{};
657
658 final details = data['details'];
659 final familyNames = <String>{};
660 if (details is Map) {
661 final family = details['family']?.toString().trim().toLowerCase();
662 if (family != null && family.isNotEmpty) familyNames.add(family);
663 final families = details['families'];
664 if (families is List) {
665 familyNames.addAll(
666 families
667 .map((value) => value.toString().trim().toLowerCase())
668 .where((value) => value.isNotEmpty),
669 );
670 }
671 }
672 final isGptOss = familyNames.any(
673 (family) => family == 'gptoss' || family == 'gpt-oss',
674 );
675 final modelfile = data['modelfile']?.toString().toLowerCase() ?? '';
676 final normalizedModelId = modelId.trim().toLowerCase();
677 // Ollama currently advertises only the broad `thinking` capability, not
678 // whether a model can actually stop generating a reasoning trace. The
679 // LFM2.5 Thinking renderer is reasoning-only: `think: false` disables its
680 // parser and leaks the raw <think> block into message.content instead of
681 // disabling generation (ollama/ollama#14622). Treat that exact renderer
682 // as mandatory so LocalMind keeps the parser enabled and the channels
683 // separate. The model-id check covers older /api/show responses that omit
684 // renderer metadata.
685 final isLfmThinkingOnly =
686 familyNames.contains('lfm2') &&
687 (modelfile.contains('renderer lfm2-thinking') ||
688 modelfile.contains('parser lfm2-thinking') ||
689 normalizedModelId.startsWith('lfm2.5-thinking:') ||
690 normalizedModelId == 'lfm2.5-thinking');
691 final supportsReasoning =
692 capabilities.contains('thinking') || isGptOss || isLfmThinkingOnly;
693 final reasoningMandatory = isGptOss || isLfmThinkingOnly;
694
695 return (
696 supportsVision: capabilities.contains('vision'),
697 supportsReasoning: supportsReasoning,
698 supportsToolUse: capabilities.contains('tools'),
699 supportedReasoningEfforts: supportsReasoning
700 ? (isGptOss
701 ? const ['low', 'medium', 'high']
702 : isLfmThinkingOnly
703 ? const ['on']
704 : const ['off', 'on'])
705 : null,
706 defaultReasoningEffort: null,
707 reasoningMandatory: reasoningMandatory,
708 );
709 }
710
711 List<ModelInfo> _parseOllamaModels(dynamic data, Server server) {
712 final List<ModelInfo> models = [];
713 final modelsData = data['models'];
714 if (modelsData is List) {
715 for (final item in modelsData) {
716 if (item is! Map) continue;
717 final detailsVal = item['details'];
718 final details = (detailsVal is Map) ? detailsVal : <String, dynamic>{};
719 final paramSize = details['parameter_size'] as String? ?? '';
720 models.add(
721 ModelInfo(
722 id: item['name']?.toString() ?? '',
723 name: _formatModelName(item['name']?.toString() ?? ''),
724 fileSize: item['size'] as int?,
725 parameterCount: _parseParameterSize(paramSize),
726 quantization: details['quantization_level'] as String?,
727 architecture: details['family'] as String?,
728 serverType: server.type,
729 serverId: server.id,
730 modifiedAt: item['modified_at'] != null
731 ? DateTime.tryParse(item['modified_at']?.toString() ?? '')
732 : null,
733 ),
734 );
735 }
736 }
737 return models;
738 }
739
740 List<ModelInfo> _parseOpenRouterModels(dynamic data, Server server) {
741 final List<ModelInfo> models = [];
742 if (data == null) return models;
743 final items = data['data'];
744 if (items is! List) return models;
745 for (final item in items) {
746 if (item is! Map) continue;
747 final modality = item['architecture']?['modality'] as String?;
748 final isTextModel =
749 modality == null ||
750 modality.isEmpty ||
751 !modality.contains('->') ||
752 modality.split('->').last.contains('text');
753 if (isTextModel) {
754 final capabilities = _parseOpenRouterCapabilities(item);
755 final pricing = _parseOpenRouterPricing(item['pricing']);
756 models.add(
757 ModelInfo(
758 id: item['id']?.toString() ?? '',
759 name: item['name']?.toString() ?? '',
760 description: item['description'] as String?,
761 contextLength: item['context_length'] as int?,
762 architecture: item['architecture']?['tokenizer'] as String?,
763 serverType: server.type,
764 serverId: server.id,
765 supportsVision: capabilities.supportsVision,
766 supportsReasoning: capabilities.supportsReasoning,
767 supportsToolUse: capabilities.supportsToolUse,
768 supportedReasoningEfforts: capabilities.supportedReasoningEfforts,
769 defaultReasoningEffort: capabilities.defaultReasoningEffort,
770 reasoningMandatory: capabilities.reasoningMandatory,
771 inputPricePerMillion: pricing.inputPricePerMillion,
772 outputPricePerMillion: pricing.outputPricePerMillion,
773 ),
774 );
775 }
776 }
777 return models;
778 }
779
780 ({
781 bool supportsVision,
782 bool supportsReasoning,
783 bool supportsToolUse,
784 List<String>? supportedReasoningEfforts,
785 String? defaultReasoningEffort,
786 bool reasoningMandatory,
787 })
788 _parseOpenRouterCapabilities(Map<dynamic, dynamic> item) {
789 final architecture = item['architecture'];
790 final arch = architecture is Map ? architecture : const <String, dynamic>{};
791 final inputModalities =
792 (arch['input_modalities'] as List?)
793 ?.map((e) => e.toString().trim().toLowerCase())
794 .toSet() ??
795 <String>{};
796 final modality = arch['modality']?.toString().toLowerCase() ?? '';
797 final supportsVision =
798 inputModalities.contains('image') || modality.contains('image');
799
800 final supportedParams =
801 (item['supported_parameters'] as List?)
802 ?.map((e) => e.toString().trim().toLowerCase())
803 .toSet() ??
804 <String>{};
805 final supportsToolUse =
806 supportedParams.contains('tools') ||
807 supportedParams.contains('tool_choice');
808
809 final reasoningField = item['reasoning'];
810 final reasoning = reasoningField is Map ? reasoningField : null;
811 final supportedEffortsRaw = reasoning?['supported_efforts'];
812 final supportedReasoningEfforts = supportedEffortsRaw is List
813 ? supportedEffortsRaw
814 .map((e) => e.toString().trim().toLowerCase())
815 .where((e) => e.isNotEmpty)
816 .toList(growable: false)
817 : null;
818 final defaultReasoningEffort = reasoning?['default_effort']
819 ?.toString()
820 .trim()
821 .toLowerCase();
822 final reasoningMandatory = reasoning?['mandatory'] == true;
823 final supportsReasoning =
824 reasoning != null && reasoning.isNotEmpty ||
825 supportedReasoningEfforts != null &&
826 supportedReasoningEfforts.isNotEmpty ||
827 reasoningMandatory ||
828 supportedParams.contains('reasoning') ||
829 supportedParams.contains('include_reasoning');
830
831 return (
832 supportsVision: supportsVision,
833 supportsReasoning: supportsReasoning,
834 supportsToolUse: supportsToolUse,
835 supportedReasoningEfforts: supportedReasoningEfforts,
836 defaultReasoningEffort: defaultReasoningEffort,
837 reasoningMandatory: reasoningMandatory,
838 );
839 }
840
841 /// Parses OpenRouter `pricing` into USD-per-1M-token prices. OpenRouter
842 /// quotes prices per token (e.g. `0.0000025`), so the value is scaled by
843 /// 1,000,000 for display-friendly numbers.
844 ({double? inputPricePerMillion, double? outputPricePerMillion})
845 _parseOpenRouterPricing(dynamic raw) {
846 if (raw is! Map) {
847 return (inputPricePerMillion: null, outputPricePerMillion: null);
848 }
849 return (
850 inputPricePerMillion: _openRouterPricePerMillion(raw['prompt']),
851 outputPricePerMillion: _openRouterPricePerMillion(raw['completion']),
852 );
853 }
854
855 double? _openRouterPricePerMillion(dynamic value) {
856 if (value == null) return null;
857 final parsed = double.tryParse(value.toString());
858 if (parsed == null || !parsed.isFinite || parsed < 0) return null;
859 return parsed * 1000000;
860 }
861
862 /// Requesty's `/models` endpoint returns the full catalog (only the
863 /// org-approved models when a key is sent). Managed policies from
864 /// `/models/managed` are listed first, since they are the curated,
865 /// multi-provider ids most users should pick. If that call fails the
866 /// catalog alone is returned.
867 Future<List<ModelInfo>> _fetchRequestyModels(
868 dynamic catalogData,
869 Server server,
870 ) async {
871 final catalog = _parseRequestyModels(catalogData, server);
872 List<ModelInfo> managed = const [];
873 try {
874 final response = await _dio.get(
875 '${server.baseUrl}/models/managed',
876 options: Options(headers: buildServerAuthHeaders(server)),
877 );
878 managed = _parseRequestyModels(response.data, server);
879 } catch (e) {
880 Log.warning('Failed to fetch Requesty managed models: $e');
881 }
882 final managedIds = managed.map((m) => m.id).toSet();
883 return [...managed, ...catalog.where((m) => !managedIds.contains(m.id))];
884 }
885
886 List<ModelInfo> _parseRequestyModels(dynamic data, Server server) {
887 final List<ModelInfo> models = [];
888 if (data is! Map) return models;
889 final items = data['data'];
890 if (items is! List) return models;
891 for (final item in items) {
892 if (item is! Map) continue;
893 final id = item['id']?.toString() ?? '';
894 if (id.isEmpty) continue;
895 final api = item['api']?.toString();
896 if (api != null && api != 'chat') continue;
897 models.add(
898 ModelInfo(
899 id: id,
900 name: item['name']?.toString() ?? id,
901 description: item['description'] as String?,
902 contextLength: _toInt(item['context_window']),
903 serverType: server.type,
904 serverId: server.id,
905 supportsVision: item['supports_vision'] == true,
906 supportsReasoning: item['supports_reasoning'] == true,
907 supportsToolUse: item['supports_tool_calling'] == true,
908 inputPricePerMillion: _openRouterPricePerMillion(item['input_price']),
909 outputPricePerMillion: _openRouterPricePerMillion(
910 item['output_price'],
911 ),
912 ),
913 );
914 }
915 return models;
916 }
917
918 String _formatModelName(String id) {
919 return id
920 .replaceAll('-', ' ')
921 .replaceAll('_', ' ')
922 .split(' ')
923 .map(
924 (w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1)}' : '',
925 )
926 .join(' ');
927 }
928
929 double? _parseParameterSize(String size) {
930 final cleaned = size.trim();
931 if (cleaned.endsWith('B') || cleaned.endsWith('b')) {
932 return double.tryParse(cleaned.substring(0, cleaned.length - 1).trim());
933 }
934 if (cleaned.endsWith('M') || cleaned.endsWith('m')) {
935 final val = double.tryParse(
936 cleaned.substring(0, cleaned.length - 1).trim(),
937 );
938 return val != null ? val / 1000 : null;
939 }
940 return double.tryParse(cleaned);
941 }
942
943 double? _parseParameterString(String? paramsString) {
944 if (paramsString == null) return null;
945 final regex = RegExp(r'(\d+(?:\.\d+)?)\s*[Bb]');
946 final match = regex.firstMatch(paramsString);
947 if (match != null) {
948 return double.tryParse(match.group(1) ?? '');
949 }
950 return null;
951 }
952
953 void _throwIfErrorResponse(dynamic data) {
954 if (data == null) return;
955 dynamic map = data;
956 if (data is String) {
957 final trimmed = data.trim();
958 if (trimmed.startsWith('{')) {
959 try {
960 map = jsonDecode(trimmed);
961 } catch (_) {
962 return;
963 }
964 } else {
965 return;
966 }
967 }
968 if (map is! Map) return;
969
970 final hasError = map.containsKey('error') && map['error'] != null;
971 final isFailedStatus =
972 map['status'] == 'error' ||
973 map['status'] == 'failed' ||
974 map['success'] == false;
975
976 if (hasError || isFailedStatus) {
977 final message = _extractApiErrorMessage(map);
978 if (message != null && message.isNotEmpty) {
979 throw Exception(message);
980 }
981 }
982 }
983
984 String? _extractApiErrorMessage(dynamic data) {
985 if (data == null) return null;
986 dynamic map = data;
987 if (data is String) {
988 final trimmed = data.trim();
989 if (trimmed.startsWith('{')) {
990 try {
991 map = jsonDecode(trimmed);
992 } catch (_) {
993 return trimmed.isNotEmpty ? trimmed : null;
994 }
995 } else {
996 return trimmed.isNotEmpty ? trimmed : null;
997 }
998 }
999 if (map is! Map) return null;
1000
1001 final error = map['error'];
1002 if (error is Map) {
1003 final type = error['type']?.toString();
1004 final message = error['message']?.toString();
1005 final code = error['code']?.toString();
1006 if (message != null && message.trim().isNotEmpty) {
1007 if (type != null && type.trim().isNotEmpty) return '$type: $message';
1008 if (code != null && code.trim().isNotEmpty) {
1009 return 'Error $code: $message';
1010 } else {
1011 return message;
1012 }
1013 }
1014 return type ?? code;
1015 } else if (error is String && error.trim().isNotEmpty) {
1016 return error;
1017 }
1018
1019 final message = map['message']?.toString();
1020 final type = map['type']?.toString();
1021 final detail = map['detail'];
1022 if (message != null && message.trim().isNotEmpty) {
1023 if (type != null && type.trim().isNotEmpty) return '$type: $message';
1024 return message;
1025 }
1026
1027 final errorDescription = map['error_description']?.toString();
1028 if (errorDescription != null && errorDescription.trim().isNotEmpty) {
1029 return errorDescription;
1030 }
1031
1032 if (detail is String && detail.trim().isNotEmpty) {
1033 if (type != null && type.trim().isNotEmpty) return '$type: $detail';
1034 return detail;
1035 } else if (detail is List && detail.isNotEmpty) {
1036 final msgs = detail
1037 .map((d) => d is Map ? d['msg']?.toString() : d?.toString())
1038 .where((m) => m != null && m.trim().isNotEmpty)
1039 .join(', ');
1040 if (msgs.isNotEmpty) return msgs;
1041 }
1042
1043 final title = map['title']?.toString();
1044 if (title != null && title.trim().isNotEmpty) {
1045 return title;
1046 }
1047
1048 return null;
1049 }
1050 }