import 'package:freezed_annotation/freezed_annotation.dart'; import 'toggle_filter.dart'; part 'model.freezed.dart'; bool? _safeBool(dynamic value) { if (value == null) return null; if (value is bool) return value; if (value is String) { final lower = value.toLowerCase(); if (lower == 'true' || lower == '1') return true; if (lower == 'false' || lower == '0') return false; } if (value is num) return value != 0; return null; } @freezed sealed class Model with _$Model { const Model._(); const factory Model({ required String id, required String name, String? description, @Default(false) bool isMultimodal, @Default(false) bool supportsStreaming, @Default(false) bool supportsRAG, Map? capabilities, Map? metadata, List? supportedParameters, List? toolIds, /// Toggleable filters that can be enabled/disabled per chat. /// These come from OpenWebUI filters with `toggle = True`. List? filters, }) = _Model; factory Model.fromJson(Map json) { final cachedIsMultimodal = switch (json['isMultimodal']) { final bool value => value, _ => json['is_multimodal'] is bool ? json['is_multimodal'] as bool : null, }; final cachedSupportsStreaming = switch (json['supportsStreaming']) { final bool value => value, _ => json['supports_streaming'] is bool ? json['supports_streaming'] as bool : null, }; // Handle different response formats from OpenWebUI // Extract architecture info for capabilities final architecture = json['architecture'] as Map?; final modality = architecture?['modality'] as String?; final inputModalities = architecture?['input_modalities'] as List?; // Determine if multimodal based on architecture final isMultimodal = cachedIsMultimodal ?? (modality?.contains('image') == true || inputModalities?.contains('image') == true); // Extract supported parameters robustly (top-level or nested under provider keys) List? supportedParams = (json['supported_parameters'] as List?) ?? (json['supportedParameters'] as List?); if (supportedParams == null) { const providerKeys = [ 'openai', 'anthropic', 'google', 'meta', 'mistral', 'cohere', 'xai', 'perplexity', 'deepseek', 'groq', ]; for (final key in providerKeys) { final provider = json[key] as Map?; final list = (provider?['supported_parameters'] as List?) ?? (provider?['supportedParameters'] as List?); if (list != null) { supportedParams = list; break; } } } // Determine streaming support from supported parameters if known final supportsStreaming = cachedSupportsStreaming ?? supportedParams?.contains('stream') ?? true; // Convert supported parameters to List if present final supportedParamsList = supportedParams ?.map((e) => e.toString()) .toList(); final baseMetadata = Map.from( (json['metadata'] as Map?) ?? const {}, ); final metaSection = json['meta'] as Map?; final infoSection = json['info'] as Map?; String? profileImage = json['profile_image_url'] as String?; profileImage ??= baseMetadata['profile_image_url'] as String?; profileImage ??= metaSection?['profile_image_url'] as String?; profileImage ??= (infoSection?['meta'] as Map?)?['profile_image_url'] as String?; final mergedMetadata = { ...baseMetadata, if (json['canonical_slug'] != null) 'canonical_slug': baseMetadata['canonical_slug'] ?? json['canonical_slug'], if (json['created'] != null) 'created': baseMetadata['created'] ?? json['created'], if (json['connection_type'] != null) 'connection_type': baseMetadata['connection_type'] ?? json['connection_type'], }; if (profileImage != null && profileImage.isNotEmpty) { mergedMetadata['profile_image_url'] = profileImage; } if (metaSection != null) { final existing = (mergedMetadata['meta'] as Map?) ?? const {}; mergedMetadata['meta'] = {...existing, ...metaSection}; } if (infoSection != null) { final existingInfo = (mergedMetadata['info'] as Map?) ?? const {}; mergedMetadata['info'] = {...existingInfo, ...infoSection}; } // Extract toolIds from info.meta.toolIds (OpenWebUI format) List? toolIds; final infoMeta = (infoSection?['meta'] as Map?) ?? (metaSection) ?? (mergedMetadata['meta'] as Map?); if (infoMeta != null) { final toolIdsData = infoMeta['toolIds']; if (toolIdsData is List) { toolIds = toolIdsData.map((e) => e.toString()).toList(); } } // Extract usage capability from info.meta.capabilities (OpenWebUI format) // This indicates whether the model supports stream_options.include_usage final infoMetaCapabilities = infoMeta?['capabilities'] as Map?; final supportsUsage = infoMetaCapabilities?['usage'] == true; // Fallback to top-level toolIds (for cached models serialized via toJson) if (toolIds == null || toolIds.isEmpty) { final topLevelToolIds = json['toolIds']; if (topLevelToolIds is List) { toolIds = topLevelToolIds.map((e) => e.toString()).toList(); } } // Extract toggle filters from the model response // These come from OpenWebUI filters with toggle=True set List? filters; final filtersData = json['filters']; if (filtersData is List && filtersData.isNotEmpty) { filters = filtersData .whereType>() .map((f) => ToggleFilter.fromJson(f)) .toList(); } final idRaw = json['id']; final id = idRaw?.toString(); if (id == null || id.isEmpty) { throw ArgumentError('Model JSON missing required "id" field.'); } final nameRaw = json['name']; final name = (nameRaw == null || nameRaw.toString().trim().isEmpty) ? id : nameRaw.toString(); return Model( id: id, name: name, description: json['description'] as String?, isMultimodal: isMultimodal, supportsStreaming: supportsStreaming, supportsRAG: _safeBool(json['supportsRAG']) ?? false, supportedParameters: supportedParamsList, capabilities: { 'architecture': architecture, 'pricing': json['pricing'], 'context_length': json['context_length'], 'supported_parameters': supportedParamsList ?? supportedParams, 'usage': supportsUsage, }, metadata: mergedMetadata, toolIds: toolIds, filters: filters, ); } Map toJson() { final data = { 'id': id, 'name': name, 'description': description, 'isMultimodal': isMultimodal, 'supportsStreaming': supportsStreaming, 'supportsRAG': supportsRAG, 'supported_parameters': supportedParameters, 'capabilities': capabilities, 'metadata': metadata, 'architecture': capabilities?['architecture'], 'toolIds': toolIds, 'filters': filters?.map((f) => f.toJson()).toList(), }; data.removeWhere((_, value) => value == null); return data; } }