import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart'; import 'package:relationship_saver/core/llm/llm_config.dart'; import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart'; import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart'; import 'package:relationship_saver/core/observability/sentry_init.dart'; import 'package:relationship_saver/features/people/domain/person_models.dart'; import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; class LlmProviderException implements Exception { const LlmProviderException({ required this.message, required this.provider, this.statusCode, }); final String message; final LlmProvider provider; final int? statusCode; @override String toString() => message; } class LlmModelInfo { const LlmModelInfo({required this.id, String? label}) : label = label ?? id; final String id; final String label; } class LlmService { LlmService(this._ref); final Ref _ref; Future get _config async { final state = _ref.read(llmConfigProvider); if (!state.isConfigured) { throw Exception('LLM not configured'); } return state; } Future> generateSignals( List people, ) async { final config = await _config; final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey(); if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } final systemPrompt = '''You are a relationship assistant that helps users maintain meaningful connections with their friends and family. Based on the person's profile data, generate helpful signals such as: - Check-in reminders - Gift ideas - Follow-up suggestions - Relationship maintenance tips Return your response as a JSON array of signals, each with: - title: short actionable title - description: brief explanation - type: "recommendation", "gift", "followup", or "reminder" - personId: the person's ID (use a valid ID from the input if applicable) Only return the JSON array, no other text.'''; final peopleContext = people .map( (p) => '- ${p.name} (${p.relationship}): tags=${p.tags.join(", ")}, notes=${p.notes.isEmpty ? "none" : p.notes}, affinity=${p.affinityScore}', ) .join('\n'); final userPrompt = '''Here are the people in my life: $peopleContext Generate 3-5 helpful relationship signals for these people.'''; try { final response = await _callLlm( provider: config.provider, apiKey: apiKey, baseUrl: config.baseUrl, model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); return _parseSignalsResponse(response, people); } catch (e) { throw Exception('Failed to generate signals: $e'); } } Future completeText({ required String systemPrompt, required String userPrompt, bool enableWebSearch = false, }) async { final config = await _config; final String? apiKey = await _ref .read(llmConfigProvider.notifier) .getApiKey(); if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } return _callLlm( provider: config.provider, apiKey: apiKey, baseUrl: config.baseUrl, model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, enableWebSearch: enableWebSearch, ); } Future suggestCaptureDraft(SharedPayload payload) async { final config = await _config; final String? apiKey = await _ref .read(llmConfigProvider.notifier) .getApiKey(); if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } final String systemPrompt = '''You analyze shared messages and links for a relationship-tracking app. Return exactly one JSON object with: - type: one of "note", "like", "dislike", "importantDate", "giftIdea", "placeIdea", "activityIdea", "misc" - text: cleaned user-facing summary - label: short optional label - dateValue: ISO-8601 date if the content clearly describes an important date, otherwise null - confidence: number from 0 to 1 - isSensitive: true only if the content looks private or sensitive Be conservative. If the content is ambiguous, prefer "note". Return JSON only.'''; final String userPrompt = '''Source app: ${payload.sourceApp} Sender: ${payload.sourceDisplayName ?? "unknown"} Platform: ${payload.platform} URL: ${payload.url ?? "none"} shared_message_datetime: ${payload.sharedMessageDateTime?.toIso8601String() ?? "null"} Import received at: ${payload.receivedAt.toIso8601String()} If shared_message_datetime is present, use it as the point-in-time reference for interpreting the message. Old status or health updates should usually become historical context or durable preferences, not current check-in prompts. Raw text: ${payload.rawText}'''; try { final String response = await _callLlm( provider: config.provider, apiKey: apiKey, baseUrl: config.baseUrl, model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); return parseCapturedFactDraftSuggestion( response, fallbackPayload: payload, ); } catch (e) { throw Exception('Failed to suggest share capture draft: $e'); } } Future> listAvailableModels({ required LlmProvider provider, String? apiKey, String? baseUrl, }) async { final String? resolvedApiKey = apiKey != null && apiKey.trim().isNotEmpty ? apiKey.trim() : await _ref .read(llmConfigProvider.notifier) .getApiKey(provider: provider); if (provider.requiresApiKey && (resolvedApiKey == null || resolvedApiKey.isEmpty)) { throw LlmProviderException( provider: provider, message: '${provider.label} needs an API key before models can load.', ); } final Dio dio = _createDio(); final String resolvedBaseUrl = _normalizeBaseUrl( baseUrl?.trim().isNotEmpty == true ? baseUrl!.trim() : provider.defaultBaseUrl, ); try { return switch (provider) { LlmProvider.openai => _listOpenAIModels( dio, resolvedBaseUrl, resolvedApiKey, ), LlmProvider.anthropic => _listAnthropicModels( dio, resolvedBaseUrl, resolvedApiKey, ), LlmProvider.google => _listGoogleModels( dio, resolvedBaseUrl, resolvedApiKey, ), LlmProvider.ollama => _listOllamaModels(dio, resolvedBaseUrl), LlmProvider.openaiCompatible => _listOpenAIModels( dio, resolvedBaseUrl, resolvedApiKey, ), }; } on DioException catch (error) { throw _mapProviderError(provider, error); } } Future _callLlm({ required LlmProvider provider, required String? apiKey, required String baseUrl, required String model, required String systemPrompt, required String userPrompt, bool enableWebSearch = false, }) async { final Dio dio = provider == LlmProvider.ollama ? _createDio(receiveTimeout: const Duration(minutes: 5)) : _createDio(); final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl); await _recordDiagnostics( 'request_start', 'Starting ${provider.label} LLM request.', details: _diagnosticsDetails( provider: provider, baseUrl: resolvedBaseUrl, model: model, enableWebSearch: enableWebSearch, systemPrompt: systemPrompt, userPrompt: userPrompt, ), ); try { late final String content; switch (provider) { case LlmProvider.openai: content = await _callOpenAI( dio, resolvedBaseUrl, apiKey, model, systemPrompt, userPrompt, ); case LlmProvider.anthropic: content = await _callAnthropic( dio, resolvedBaseUrl, apiKey, model, systemPrompt, userPrompt, ); case LlmProvider.google: content = await _callGoogleAI( dio, resolvedBaseUrl, apiKey, model, systemPrompt, userPrompt, enableWebSearch: enableWebSearch, ); case LlmProvider.ollama: content = await _callOllama( dio, resolvedBaseUrl, model, systemPrompt, userPrompt, enableWebSearch: enableWebSearch, ); case LlmProvider.openaiCompatible: content = await _callOpenAI( dio, resolvedBaseUrl, apiKey, model, systemPrompt, userPrompt, ); } await _recordDiagnostics( 'request_success', '${provider.label} returned a response.', details: _diagnosticsDetails( provider: provider, baseUrl: resolvedBaseUrl, model: model, enableWebSearch: enableWebSearch, systemPrompt: systemPrompt, userPrompt: userPrompt, responseLength: content.length, ), ); return content; } on DioException catch (error) { await _recordProviderFailure( provider: provider, baseUrl: resolvedBaseUrl, model: model, enableWebSearch: enableWebSearch, systemPrompt: systemPrompt, userPrompt: userPrompt, error: error, ); throw _mapProviderError(provider, error); } } Future _recordDiagnostics( String stage, String message, { String? details, }) async { try { await _ref .read(llmDiagnosticsLogProvider) .record(stage, message, details: details); } catch (_) { // Diagnostics must never break the user-facing LLM flow. } } Future _recordProviderFailure({ required LlmProvider provider, required String baseUrl, required String model, required bool enableWebSearch, required String systemPrompt, required String userPrompt, required DioException error, }) { final int? statusCode = error.response?.statusCode; final String status = statusCode == null ? 'without HTTP status' : '$statusCode'; final Map context = _diagnosticsMap( provider: provider, baseUrl: baseUrl, model: model, enableWebSearch: enableWebSearch, systemPrompt: systemPrompt, userPrompt: userPrompt, dioType: error.type.name, statusCode: statusCode, providerMessage: _extractProviderMessage(error.response?.data), responseBody: _safeJson(error.response?.data), ); _ref .read(sentryInitProvider) .captureException( error, stackTrace: error.stackTrace, area: 'llm_provider', context: context, ); return _recordDiagnostics( 'provider_error', '${provider.label} request failed with $status.', details: const JsonEncoder.withIndent(' ').convert(context), ); } Future _callOpenAI( Dio dio, String baseUrl, String? apiKey, String model, String systemPrompt, String userPrompt, ) async { final Map headers = { 'Content-Type': 'application/json', if (apiKey != null && apiKey.isNotEmpty) 'Authorization': 'Bearer $apiKey', }; final Response> response = await dio .post>( '$baseUrl/chat/completions', options: Options(headers: headers), data: jsonEncode({ 'model': model, 'messages': [ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': userPrompt}, ], 'max_tokens': 1000, }), ); final Map responseData = response.data!; final List choices = responseData['choices'] as List; final Map firstChoice = choices[0] as Map; final Map message = firstChoice['message'] as Map; final String content = message['content'] as String; return content; } Future _callAnthropic( Dio dio, String baseUrl, String? apiKey, String model, String systemPrompt, String userPrompt, ) async { final Response> response = await dio .post>( '$baseUrl/messages', options: Options( headers: { 'x-api-key': apiKey ?? '', 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json', }, ), data: jsonEncode({ 'model': model, 'system': systemPrompt, 'messages': [ {'role': 'user', 'content': userPrompt}, ], 'max_tokens': 1000, }), ); final Map responseData = response.data!; final List contentList = responseData['content'] as List; final Map firstContent = contentList[0] as Map; final String content = firstContent['text'] as String; return content; } Future _callGoogleAI( Dio dio, String baseUrl, String? apiKey, String model, String systemPrompt, String userPrompt, { bool enableWebSearch = false, }) async { final String modelPath = model.startsWith('models/') ? model : 'models/$model'; final Response> response = await dio .post>( '$baseUrl/$modelPath:generateContent', options: Options(headers: {'Content-Type': 'application/json'}), queryParameters: {'key': apiKey ?? ''}, data: jsonEncode({ 'systemInstruction': { 'parts': [ {'text': systemPrompt}, ], }, 'contents': [ { 'parts': [ {'text': userPrompt}, ], }, ], if (enableWebSearch) 'tools': [ {'google_search': {}}, ], 'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000}, }), ); final Map responseData = response.data!; final List candidates = responseData['candidates'] as List; final Map firstCandidate = candidates[0] as Map; final Map content = firstCandidate['content'] as Map; final List parts = content['parts'] as List; final Map firstPart = parts[0] as Map; final String contentText = firstPart['text'] as String; return contentText; } Future _callOllama( Dio dio, String baseUrl, String model, String systemPrompt, String userPrompt, { bool enableWebSearch = false, }) async { if (enableWebSearch) { return _ref .read(ollamaToolChatClientProvider) .complete( baseUrl: baseUrl, model: model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); } final Response> response = await dio .post>( '$baseUrl/api/chat', options: Options(headers: {'Content-Type': 'application/json'}), data: jsonEncode({ 'model': model, 'stream': false, 'messages': [ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': userPrompt}, ], }), ); final Map responseData = response.data!; final Map message = responseData['message'] as Map; return message['content'] as String; } Future> _listOpenAIModels( Dio dio, String baseUrl, String? apiKey, ) async { final Response> response = await dio .get>( '$baseUrl/models', options: Options( headers: { if (apiKey != null && apiKey.isNotEmpty) 'Authorization': 'Bearer $apiKey', }, ), ); final List data = response.data!['data'] as List; return _sortModels( data .map((dynamic item) => item as Map) .map((Map item) => item['id'] as String) .map((String id) => LlmModelInfo(id: id)), ); } Future> _listAnthropicModels( Dio dio, String baseUrl, String? apiKey, ) async { final Response> response = await dio .get>( '$baseUrl/models', options: Options( headers: { 'x-api-key': apiKey ?? '', 'anthropic-version': '2023-06-01', }, ), ); final List data = response.data!['data'] as List; return _sortModels( data.map((dynamic item) { final Map model = item as Map; final String id = model['id'] as String; return LlmModelInfo(id: id, label: model['display_name'] as String?); }), ); } Future> _listGoogleModels( Dio dio, String baseUrl, String? apiKey, ) async { final Response> response = await dio .get>( '$baseUrl/models', queryParameters: {'key': apiKey ?? ''}, ); final List data = response.data!['models'] as List; return _sortModels( data .map((dynamic item) => item as Map) .where((Map item) { final Object? methods = item['supportedGenerationMethods']; return methods is List && methods.whereType().contains('generateContent'); }) .map((Map item) { final String name = item['name'] as String; final String id = name.startsWith('models/') ? name.substring('models/'.length) : name; return LlmModelInfo( id: id, label: item['displayName'] as String? ?? id, ); }), ); } Future> _listOllamaModels(Dio dio, String baseUrl) async { final Response> response = await dio .get>('$baseUrl/api/tags'); final List data = response.data!['models'] as List; return _sortModels( data.map((dynamic item) => item as Map).map(( Map item, ) { final Object? name = item['name'] ?? item['model']; return LlmModelInfo(id: name as String); }), ); } List _parseSignalsResponse( String response, List people, ) { try { final jsonStart = response.indexOf('['); final jsonEnd = response.lastIndexOf(']'); if (jsonStart == -1 || jsonEnd == -1) { return []; } final jsonStr = response.substring(jsonStart, jsonEnd + 1); final List items = jsonDecode(jsonStr) as List; return items.map((item) { final Map itemMap = item as Map; final String? personId = itemMap['personId'] as String?; final validPersonId = personId != null && people.any((p) => p.id == personId) ? personId : (people.isNotEmpty ? people.first.id : null); return GeneratedSignal( title: itemMap['title'] as String? ?? 'Check in', description: itemMap['description'] as String? ?? '', type: itemMap['type'] as String? ?? 'recommendation', personId: validPersonId, ); }).toList(); } catch (e) { return []; } } } LlmProviderException _mapProviderError( LlmProvider provider, DioException error, ) { final int? statusCode = error.response?.statusCode; final String providerName = _providerLabel(provider); final String? providerMessage = _extractProviderMessage(error.response?.data); if (statusCode == 429) { final String detail = providerMessage == null ? '' : ' $providerMessage'; return LlmProviderException( provider: provider, statusCode: statusCode, message: '$providerName rejected the digest request with 429 rate limiting or quota pressure.$detail Check that the API key has available billing/quota, wait for the provider limit to reset, or switch to another configured provider.', ); } if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.sendTimeout || error.type == DioExceptionType.receiveTimeout) { return LlmProviderException( provider: provider, statusCode: statusCode, message: '$providerName timed out while running the digest request.', ); } if (error.type == DioExceptionType.connectionError) { return LlmProviderException( provider: provider, statusCode: statusCode, message: 'Could not reach $providerName. Check the network connection and provider endpoint availability.', ); } final String statusText = statusCode == null ? '' : ' HTTP $statusCode.'; final String detail = providerMessage == null ? '' : ' $providerMessage'; return LlmProviderException( provider: provider, statusCode: statusCode, message: '$providerName could not complete the digest request.$statusText$detail', ); } String _diagnosticsDetails({ required LlmProvider provider, required String baseUrl, required String model, required bool enableWebSearch, required String systemPrompt, required String userPrompt, int? responseLength, String? dioType, int? statusCode, String? providerMessage, String? responseBody, }) { return const JsonEncoder.withIndent(' ').convert( _diagnosticsMap( provider: provider, baseUrl: baseUrl, model: model, enableWebSearch: enableWebSearch, systemPrompt: systemPrompt, userPrompt: userPrompt, responseLength: responseLength, dioType: dioType, statusCode: statusCode, providerMessage: providerMessage, responseBody: responseBody, ), ); } Map _diagnosticsMap({ required LlmProvider provider, required String baseUrl, required String model, required bool enableWebSearch, required String systemPrompt, required String userPrompt, int? responseLength, String? dioType, int? statusCode, String? providerMessage, String? responseBody, }) { final Map diagnostics = { 'provider': provider.name, 'baseUrl': baseUrl, 'model': model, 'webSearchRequested': enableWebSearch, 'systemPromptChars': systemPrompt.length, 'userPromptChars': userPrompt.length, }; if (responseLength != null) { diagnostics['responseChars'] = responseLength; } if (dioType != null) { diagnostics['dioType'] = dioType; } if (statusCode != null) { diagnostics['statusCode'] = statusCode; } if (providerMessage != null) { diagnostics['providerMessage'] = providerMessage; } if (responseBody != null) { diagnostics['responseBody'] = responseBody; } return diagnostics; } String? _safeJson(Object? data) { if (data == null) { return null; } try { final String encoded = data is String ? data : const JsonEncoder.withIndent(' ').convert(data); return _compactDiagnosticValue(encoded); } catch (_) { return _compactDiagnosticValue('$data'); } } String _compactDiagnosticValue(String value) { final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' '); if (compacted.length <= 3000) { return compacted; } return '${compacted.substring(0, 2997)}...'; } String _normalizeBaseUrl(String baseUrl) { String normalized = baseUrl.trim(); while (normalized.endsWith('/')) { normalized = normalized.substring(0, normalized.length - 1); } return normalized; } Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) { return Dio( BaseOptions( connectTimeout: const Duration(seconds: 20), receiveTimeout: receiveTimeout, sendTimeout: const Duration(seconds: 20), ), ); } List _sortModels(Iterable models) { final List sorted = models.toList(); sorted.sort( (LlmModelInfo a, LlmModelInfo b) => a.label.toLowerCase().compareTo(b.label.toLowerCase()), ); return sorted; } String _providerLabel(LlmProvider provider) { return provider.label; } String? _extractProviderMessage(Object? data) { if (data is Map) { final Object? direct = data['message']; if (direct is String && direct.trim().isNotEmpty) { return _compactProviderMessage(direct); } final Object? error = data['error']; if (error is String && error.trim().isNotEmpty) { return _compactProviderMessage(error); } if (error is Map) { final Object? nested = error['message']; if (nested is String && nested.trim().isNotEmpty) { return _compactProviderMessage(nested); } } } return null; } String _compactProviderMessage(String value) { final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' '); if (compacted.length <= 240) { return compacted; } return '${compacted.substring(0, 237)}...'; } class GeneratedSignal { const GeneratedSignal({ required this.title, required this.description, required this.type, this.personId, }); final String title; final String description; final String type; final String? personId; } final llmServiceProvider = Provider((Ref ref) { return LlmService(ref); });