901 lines
27 KiB
Dart
901 lines
27 KiB
Dart
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<LlmConfigState> get _config async {
|
|
final state = _ref.read(llmConfigProvider);
|
|
if (!state.isConfigured) {
|
|
throw Exception('LLM not configured');
|
|
}
|
|
return state;
|
|
}
|
|
|
|
Future<List<GeneratedSignal>> generateSignals(
|
|
List<PersonProfile> 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<String> 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<CapturedFactDraft?> 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<List<LlmModelInfo>> 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<String> _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<void> _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<void> _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<String, dynamic> 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<String> _callOpenAI(
|
|
Dio dio,
|
|
String baseUrl,
|
|
String? apiKey,
|
|
String model,
|
|
String systemPrompt,
|
|
String userPrompt,
|
|
) async {
|
|
final Map<String, String> headers = <String, String>{
|
|
'Content-Type': 'application/json',
|
|
if (apiKey != null && apiKey.isNotEmpty)
|
|
'Authorization': 'Bearer $apiKey',
|
|
};
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.post<Map<String, dynamic>>(
|
|
'$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<String, dynamic> responseData = response.data!;
|
|
final List<dynamic> choices = responseData['choices'] as List<dynamic>;
|
|
final Map<String, dynamic> firstChoice = choices[0] as Map<String, dynamic>;
|
|
final Map<String, dynamic> message =
|
|
firstChoice['message'] as Map<String, dynamic>;
|
|
final String content = message['content'] as String;
|
|
return content;
|
|
}
|
|
|
|
Future<String> _callAnthropic(
|
|
Dio dio,
|
|
String baseUrl,
|
|
String? apiKey,
|
|
String model,
|
|
String systemPrompt,
|
|
String userPrompt,
|
|
) async {
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.post<Map<String, dynamic>>(
|
|
'$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<String, dynamic> responseData = response.data!;
|
|
final List<dynamic> contentList = responseData['content'] as List<dynamic>;
|
|
final Map<String, dynamic> firstContent =
|
|
contentList[0] as Map<String, dynamic>;
|
|
final String content = firstContent['text'] as String;
|
|
return content;
|
|
}
|
|
|
|
Future<String> _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<Map<String, dynamic>> response = await dio
|
|
.post<Map<String, dynamic>>(
|
|
'$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': <String, dynamic>{}},
|
|
],
|
|
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
|
}),
|
|
);
|
|
|
|
final Map<String, dynamic> responseData = response.data!;
|
|
final List<dynamic> candidates =
|
|
responseData['candidates'] as List<dynamic>;
|
|
final Map<String, dynamic> firstCandidate =
|
|
candidates[0] as Map<String, dynamic>;
|
|
final Map<String, dynamic> content =
|
|
firstCandidate['content'] as Map<String, dynamic>;
|
|
final List<dynamic> parts = content['parts'] as List<dynamic>;
|
|
final Map<String, dynamic> firstPart = parts[0] as Map<String, dynamic>;
|
|
final String contentText = firstPart['text'] as String;
|
|
return contentText;
|
|
}
|
|
|
|
Future<String> _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<Map<String, dynamic>> response = await dio
|
|
.post<Map<String, dynamic>>(
|
|
'$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<String, dynamic> responseData = response.data!;
|
|
final Map<String, dynamic> message =
|
|
responseData['message'] as Map<String, dynamic>;
|
|
return message['content'] as String;
|
|
}
|
|
|
|
Future<List<LlmModelInfo>> _listOpenAIModels(
|
|
Dio dio,
|
|
String baseUrl,
|
|
String? apiKey,
|
|
) async {
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.get<Map<String, dynamic>>(
|
|
'$baseUrl/models',
|
|
options: Options(
|
|
headers: <String, String>{
|
|
if (apiKey != null && apiKey.isNotEmpty)
|
|
'Authorization': 'Bearer $apiKey',
|
|
},
|
|
),
|
|
);
|
|
final List<dynamic> data = response.data!['data'] as List<dynamic>;
|
|
return _sortModels(
|
|
data
|
|
.map((dynamic item) => item as Map<String, dynamic>)
|
|
.map((Map<String, dynamic> item) => item['id'] as String)
|
|
.map((String id) => LlmModelInfo(id: id)),
|
|
);
|
|
}
|
|
|
|
Future<List<LlmModelInfo>> _listAnthropicModels(
|
|
Dio dio,
|
|
String baseUrl,
|
|
String? apiKey,
|
|
) async {
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.get<Map<String, dynamic>>(
|
|
'$baseUrl/models',
|
|
options: Options(
|
|
headers: <String, String>{
|
|
'x-api-key': apiKey ?? '',
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
),
|
|
);
|
|
final List<dynamic> data = response.data!['data'] as List<dynamic>;
|
|
return _sortModels(
|
|
data.map((dynamic item) {
|
|
final Map<String, dynamic> model = item as Map<String, dynamic>;
|
|
final String id = model['id'] as String;
|
|
return LlmModelInfo(id: id, label: model['display_name'] as String?);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<List<LlmModelInfo>> _listGoogleModels(
|
|
Dio dio,
|
|
String baseUrl,
|
|
String? apiKey,
|
|
) async {
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.get<Map<String, dynamic>>(
|
|
'$baseUrl/models',
|
|
queryParameters: <String, String>{'key': apiKey ?? ''},
|
|
);
|
|
final List<dynamic> data = response.data!['models'] as List<dynamic>;
|
|
return _sortModels(
|
|
data
|
|
.map((dynamic item) => item as Map<String, dynamic>)
|
|
.where((Map<String, dynamic> item) {
|
|
final Object? methods = item['supportedGenerationMethods'];
|
|
return methods is List<dynamic> &&
|
|
methods.whereType<String>().contains('generateContent');
|
|
})
|
|
.map((Map<String, dynamic> 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<List<LlmModelInfo>> _listOllamaModels(Dio dio, String baseUrl) async {
|
|
final Response<Map<String, dynamic>> response = await dio
|
|
.get<Map<String, dynamic>>('$baseUrl/api/tags');
|
|
final List<dynamic> data = response.data!['models'] as List<dynamic>;
|
|
return _sortModels(
|
|
data.map((dynamic item) => item as Map<String, dynamic>).map((
|
|
Map<String, dynamic> item,
|
|
) {
|
|
final Object? name = item['name'] ?? item['model'];
|
|
return LlmModelInfo(id: name as String);
|
|
}),
|
|
);
|
|
}
|
|
|
|
List<GeneratedSignal> _parseSignalsResponse(
|
|
String response,
|
|
List<PersonProfile> 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<dynamic> items = jsonDecode(jsonStr) as List<dynamic>;
|
|
|
|
return items.map((item) {
|
|
final Map<String, dynamic> itemMap = item as Map<String, dynamic>;
|
|
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<String, dynamic> _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<String, dynamic> diagnostics = <String, dynamic>{
|
|
'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<LlmModelInfo> _sortModels(Iterable<LlmModelInfo> models) {
|
|
final List<LlmModelInfo> 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<String, dynamic>) {
|
|
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<String, dynamic>) {
|
|
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<LlmService>((Ref ref) {
|
|
return LlmService(ref);
|
|
});
|