| 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 | } |