Improve LLM provider configuration

This commit is contained in:
Rijad Zuzo
2026-05-19 11:14:23 +02:00
parent d81215db73
commit e13d8b76cd
5 changed files with 1077 additions and 151 deletions
+413 -52
View File
@@ -7,6 +7,28 @@ import 'package:relationship_saver/core/llm/llm_config.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);
@@ -26,7 +48,7 @@ class LlmService {
final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey();
if (apiKey == null || apiKey.isEmpty) {
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
@@ -63,6 +85,8 @@ Generate 3-5 helpful relationship signals for these people.''';
final response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
@@ -82,13 +106,15 @@ Generate 3-5 helpful relationship signals for these people.''';
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
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,
);
@@ -100,7 +126,7 @@ Generate 3-5 helpful relationship signals for these people.''';
.read(llmConfigProvider.notifier)
.getApiKey();
if (apiKey == null || apiKey.isEmpty) {
if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available');
}
@@ -128,6 +154,8 @@ ${payload.rawText}''';
final String response = await _callLlm(
provider: config.provider,
apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
@@ -140,47 +168,143 @@ ${payload.rawText}''';
}
}
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? apiKey,
required String baseUrl,
required String model,
required String systemPrompt,
required String userPrompt,
}) async {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
final Dio dio = _createDio();
final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.anthropic:
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt);
case LlmProvider.google:
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt);
try {
switch (provider) {
case LlmProvider.openai:
return _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.anthropic:
return _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.google:
return _callGoogleAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.ollama:
return _callOllama(
dio,
resolvedBaseUrl,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.openaiCompatible:
return _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
}
} on DioException catch (error) {
throw _mapProviderError(provider, error);
}
}
Future<String> _callOpenAI(
Dio dio,
String apiKey,
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>>(
'https://api.openai.com/v1/chat/completions',
options: Options(
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
'$baseUrl/chat/completions',
options: Options(headers: headers),
data: jsonEncode({
'model': 'gpt-4o-mini',
'model': model,
'messages': [
{'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt},
@@ -200,22 +324,24 @@ ${payload.rawText}''';
Future<String> _callAnthropic(
Dio dio,
String apiKey,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'https://api.anthropic.com/v1/messages',
'$baseUrl/messages',
options: Options(
headers: {
'x-api-key': apiKey,
'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
),
data: jsonEncode({
'model': 'claude-3-haiku-20240307',
'model': model,
'system': systemPrompt,
'messages': [
{'role': 'user', 'content': userPrompt},
@@ -234,31 +360,36 @@ ${payload.rawText}''';
Future<String> _callGoogleAI(
Dio dio,
String apiKey,
String baseUrl,
String? apiKey,
String model,
String systemPrompt,
String userPrompt,
) async {
final Response<Map<String, dynamic>>
response = await dio.post<Map<String, dynamic>>(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey},
data: jsonEncode({
'systemInstruction': {
'parts': [
{'text': systemPrompt},
],
},
'contents': [
{
'parts': [
{'text': userPrompt},
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},
],
},
],
},
],
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
}),
);
final Map<String, dynamic> responseData = response.data!;
final List<dynamic> candidates =
@@ -273,6 +404,128 @@ ${payload.rawText}''';
return contentText;
}
Future<String> _callOllama(
Dio dio,
String baseUrl,
String model,
String systemPrompt,
String userPrompt,
) async {
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,
@@ -309,6 +562,114 @@ ${payload.rawText}''';
}
}
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 _normalizeBaseUrl(String baseUrl) {
String normalized = baseUrl.trim();
while (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
Dio _createDio() {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
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,