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
+213 -29
View File
@@ -2,32 +2,119 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const String _llmApiKeyStorageKey = 'llm_api_key'; const String _llmApiKeyStorageKey = 'llm_api_key';
const String _llmBaseUrlStorageKey = 'llm_base_url';
const String _llmModelStorageKey = 'llm_model';
const String _llmProviderStorageKey = 'llm_provider'; const String _llmProviderStorageKey = 'llm_provider';
enum LlmProvider { openai, anthropic, google } String _providerApiKeyStorageKey(LlmProvider provider) {
return 'llm_api_key_${provider.name}';
}
enum LlmProvider { openai, anthropic, google, ollama, openaiCompatible }
extension LlmProviderMetadata on LlmProvider {
String get label {
return switch (this) {
LlmProvider.openai => 'OpenAI',
LlmProvider.anthropic => 'Anthropic',
LlmProvider.google => 'Google AI',
LlmProvider.ollama => 'Ollama',
LlmProvider.openaiCompatible => 'OpenAI-compatible',
};
}
String get defaultBaseUrl {
return switch (this) {
LlmProvider.openai => 'https://api.openai.com/v1',
LlmProvider.anthropic => 'https://api.anthropic.com/v1',
LlmProvider.google => 'https://generativelanguage.googleapis.com/v1beta',
LlmProvider.ollama => 'http://localhost:11434',
LlmProvider.openaiCompatible => '',
};
}
String get defaultModel {
return switch (this) {
LlmProvider.openai => 'gpt-4o-mini',
LlmProvider.anthropic => 'claude-3-haiku-20240307',
LlmProvider.google => 'gemini-2.0-flash',
LlmProvider.ollama => '',
LlmProvider.openaiCompatible => '',
};
}
bool get requiresApiKey {
return switch (this) {
LlmProvider.openai || LlmProvider.anthropic || LlmProvider.google => true,
LlmProvider.ollama || LlmProvider.openaiCompatible => false,
};
}
bool get hasConfigurableBaseUrl {
return this == LlmProvider.ollama || this == LlmProvider.openaiCompatible;
}
}
class LlmConfigState { class LlmConfigState {
const LlmConfigState({ LlmConfigState({
this.apiKeyConfigured = false, bool apiKeyConfigured = false,
this.provider = LlmProvider.openai, LlmProvider provider = LlmProvider.openai,
this.isConfigured = false, String? baseUrl,
String? model,
}) : this._(
apiKeyConfigured: apiKeyConfigured,
provider: provider,
baseUrl: _resolveBaseUrl(provider, baseUrl),
model: _resolveModel(provider, model),
);
const LlmConfigState._({
required this.apiKeyConfigured,
required this.provider,
required this.baseUrl,
required this.model,
}); });
final bool apiKeyConfigured; final bool apiKeyConfigured;
final LlmProvider provider; final LlmProvider provider;
final bool isConfigured; final String baseUrl;
final String model;
bool get isConfigured {
final bool hasKey = apiKeyConfigured || !provider.requiresApiKey;
return hasKey && baseUrl.trim().isNotEmpty && model.trim().isNotEmpty;
}
LlmConfigState copyWith({ LlmConfigState copyWith({
bool? apiKeyConfigured, bool? apiKeyConfigured,
LlmProvider? provider, LlmProvider? provider,
bool? isConfigured, String? baseUrl,
String? model,
}) { }) {
final LlmProvider nextProvider = provider ?? this.provider;
return LlmConfigState( return LlmConfigState(
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured, apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
provider: provider ?? this.provider, provider: nextProvider,
isConfigured: isConfigured ?? this.isConfigured, baseUrl: baseUrl ?? this.baseUrl,
model: model ?? this.model,
); );
} }
static String _resolveBaseUrl(LlmProvider provider, String? baseUrl) {
final String? trimmed = baseUrl?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return provider.defaultBaseUrl;
}
static String _resolveModel(LlmProvider provider, String? model) {
final String? trimmed = model?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return provider.defaultModel;
}
} }
class LlmConfigNotifier extends Notifier<LlmConfigState> { class LlmConfigNotifier extends Notifier<LlmConfigState> {
@@ -35,7 +122,7 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
@override @override
LlmConfigState build() { LlmConfigState build() {
return const LlmConfigState(); return LlmConfigState();
} }
Future<void> initialize() async { Future<void> initialize() async {
@@ -49,36 +136,53 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
Future<void> _initializeFromStorage() async { Future<void> _initializeFromStorage() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage(); const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String? storedKey = await secureStorage.read(
key: _llmApiKeyStorageKey,
);
final String? storedProvider = await secureStorage.read( final String? storedProvider = await secureStorage.read(
key: _llmProviderStorageKey, key: _llmProviderStorageKey,
); );
final String? storedBaseUrl = await secureStorage.read(
key: _llmBaseUrlStorageKey,
);
final String? storedModel = await secureStorage.read(
key: _llmModelStorageKey,
);
state = LlmConfigState( final LlmProvider provider = storedProvider != null
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: storedProvider != null
? LlmProvider.values.firstWhere( ? LlmProvider.values.firstWhere(
(LlmProvider p) => p.name == storedProvider, (LlmProvider p) => p.name == storedProvider,
orElse: () => LlmProvider.openai, orElse: () => LlmProvider.openai,
) )
: LlmProvider.openai, : LlmProvider.openai;
isConfigured: storedKey != null && storedKey.isNotEmpty, String? storedKey = await _readApiKey(secureStorage, provider: provider);
if (storedKey == null || storedKey.isEmpty) {
final String? legacyKey = await secureStorage.read(
key: _llmApiKeyStorageKey,
);
if (legacyKey != null && legacyKey.isNotEmpty) {
await secureStorage.write(
key: _providerApiKeyStorageKey(provider),
value: legacyKey,
);
storedKey = legacyKey;
}
}
state = LlmConfigState(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: provider,
baseUrl: storedBaseUrl,
model: storedModel,
); );
} }
Future<void> setApiKey(String apiKey) async { Future<void> setApiKey(String apiKey) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage(); const FlutterSecureStorage secureStorage = FlutterSecureStorage();
final String key = _providerApiKeyStorageKey(state.provider);
if (apiKey.isEmpty) { if (apiKey.isEmpty) {
await secureStorage.delete(key: _llmApiKeyStorageKey); await secureStorage.delete(key: key);
} else { } else {
await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey); await secureStorage.write(key: key, value: apiKey);
} }
state = state.copyWith( state = state.copyWith(apiKeyConfigured: apiKey.isNotEmpty);
apiKeyConfigured: apiKey.isNotEmpty,
isConfigured: apiKey.isNotEmpty,
);
} }
Future<void> setProvider(LlmProvider provider) async { Future<void> setProvider(LlmProvider provider) async {
@@ -87,18 +191,98 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
key: _llmProviderStorageKey, key: _llmProviderStorageKey,
value: provider.name, value: provider.name,
); );
state = state.copyWith(provider: provider); await secureStorage.write(
key: _llmBaseUrlStorageKey,
value: provider.defaultBaseUrl,
);
await secureStorage.write(
key: _llmModelStorageKey,
value: provider.defaultModel,
);
final String? storedKey = await _readApiKey(
secureStorage,
provider: provider,
);
state = state.copyWith(
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
provider: provider,
baseUrl: provider.defaultBaseUrl,
model: provider.defaultModel,
);
} }
Future<String?> getApiKey() async { Future<void> saveConfiguration({
required LlmProvider provider,
required String baseUrl,
required String model,
String? apiKey,
bool clearApiKey = false,
}) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage(); const FlutterSecureStorage secureStorage = FlutterSecureStorage();
return secureStorage.read(key: _llmApiKeyStorageKey); await secureStorage.write(
key: _llmProviderStorageKey,
value: provider.name,
);
await secureStorage.write(
key: _llmBaseUrlStorageKey,
value: baseUrl.trim(),
);
await secureStorage.write(key: _llmModelStorageKey, value: model.trim());
final String? storedProviderKey = await _readApiKey(
secureStorage,
provider: provider,
);
bool apiKeyConfigured =
storedProviderKey != null && storedProviderKey.isNotEmpty;
if (clearApiKey) {
await secureStorage.delete(key: _providerApiKeyStorageKey(provider));
apiKeyConfigured = false;
} else if (apiKey != null) {
final String trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isEmpty) {
await secureStorage.delete(key: _providerApiKeyStorageKey(provider));
apiKeyConfigured = false;
} else {
await secureStorage.write(
key: _providerApiKeyStorageKey(provider),
value: trimmedApiKey,
);
apiKeyConfigured = true;
}
}
state = LlmConfigState(
apiKeyConfigured: apiKeyConfigured,
provider: provider,
baseUrl: baseUrl,
model: model,
);
}
Future<String?> getApiKey({LlmProvider? provider}) async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
return _readApiKey(secureStorage, provider: provider ?? state.provider);
} }
Future<void> clearApiKey() async { Future<void> clearApiKey() async {
const FlutterSecureStorage secureStorage = FlutterSecureStorage(); const FlutterSecureStorage secureStorage = FlutterSecureStorage();
await secureStorage.delete(key: _llmApiKeyStorageKey); await secureStorage.delete(key: _providerApiKeyStorageKey(state.provider));
state = state.copyWith(apiKeyConfigured: false, isConfigured: false); state = state.copyWith(apiKeyConfigured: false);
}
Future<String?> _readApiKey(
FlutterSecureStorage secureStorage, {
required LlmProvider provider,
}) async {
final String? providerKey = await secureStorage.read(
key: _providerApiKeyStorageKey(provider),
);
if (providerKey != null && providerKey.isNotEmpty) {
return providerKey;
}
return null;
} }
} }
+393 -32
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/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_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 { class LlmService {
LlmService(this._ref); LlmService(this._ref);
@@ -26,7 +48,7 @@ class LlmService {
final config = await _config; final config = await _config;
final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey(); 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'); throw Exception('No API key available');
} }
@@ -63,6 +85,8 @@ Generate 3-5 helpful relationship signals for these people.''';
final response = await _callLlm( final response = await _callLlm(
provider: config.provider, provider: config.provider,
apiKey: apiKey, apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
userPrompt: userPrompt, userPrompt: userPrompt,
); );
@@ -82,13 +106,15 @@ Generate 3-5 helpful relationship signals for these people.''';
.read(llmConfigProvider.notifier) .read(llmConfigProvider.notifier)
.getApiKey(); .getApiKey();
if (apiKey == null || apiKey.isEmpty) { if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available'); throw Exception('No API key available');
} }
return _callLlm( return _callLlm(
provider: config.provider, provider: config.provider,
apiKey: apiKey, apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
userPrompt: userPrompt, userPrompt: userPrompt,
); );
@@ -100,7 +126,7 @@ Generate 3-5 helpful relationship signals for these people.''';
.read(llmConfigProvider.notifier) .read(llmConfigProvider.notifier)
.getApiKey(); .getApiKey();
if (apiKey == null || apiKey.isEmpty) { if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) {
throw Exception('No API key available'); throw Exception('No API key available');
} }
@@ -128,6 +154,8 @@ ${payload.rawText}''';
final String response = await _callLlm( final String response = await _callLlm(
provider: config.provider, provider: config.provider,
apiKey: apiKey, apiKey: apiKey,
baseUrl: config.baseUrl,
model: config.model,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
userPrompt: userPrompt, 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({ Future<String> _callLlm({
required LlmProvider provider, required LlmProvider provider,
required String apiKey, required String? apiKey,
required String baseUrl,
required String model,
required String systemPrompt, required String systemPrompt,
required String userPrompt, required String userPrompt,
}) async { }) async {
final dio = Dio( final Dio dio = _createDio();
BaseOptions( final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl);
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
try {
switch (provider) { switch (provider) {
case LlmProvider.openai: case LlmProvider.openai:
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt); return _callOpenAI(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.anthropic: case LlmProvider.anthropic:
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt); return _callAnthropic(
dio,
resolvedBaseUrl,
apiKey,
model,
systemPrompt,
userPrompt,
);
case LlmProvider.google: case LlmProvider.google:
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt); 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( Future<String> _callOpenAI(
Dio dio, Dio dio,
String apiKey, String baseUrl,
String? apiKey,
String model,
String systemPrompt, String systemPrompt,
String userPrompt, String userPrompt,
) async { ) 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 final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>( .post<Map<String, dynamic>>(
'https://api.openai.com/v1/chat/completions', '$baseUrl/chat/completions',
options: Options( options: Options(headers: headers),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
),
data: jsonEncode({ data: jsonEncode({
'model': 'gpt-4o-mini', 'model': model,
'messages': [ 'messages': [
{'role': 'system', 'content': systemPrompt}, {'role': 'system', 'content': systemPrompt},
{'role': 'user', 'content': userPrompt}, {'role': 'user', 'content': userPrompt},
@@ -200,22 +324,24 @@ ${payload.rawText}''';
Future<String> _callAnthropic( Future<String> _callAnthropic(
Dio dio, Dio dio,
String apiKey, String baseUrl,
String? apiKey,
String model,
String systemPrompt, String systemPrompt,
String userPrompt, String userPrompt,
) async { ) async {
final Response<Map<String, dynamic>> response = await dio final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>( .post<Map<String, dynamic>>(
'https://api.anthropic.com/v1/messages', '$baseUrl/messages',
options: Options( options: Options(
headers: { headers: {
'x-api-key': apiKey, 'x-api-key': apiKey ?? '',
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
), ),
data: jsonEncode({ data: jsonEncode({
'model': 'claude-3-haiku-20240307', 'model': model,
'system': systemPrompt, 'system': systemPrompt,
'messages': [ 'messages': [
{'role': 'user', 'content': userPrompt}, {'role': 'user', 'content': userPrompt},
@@ -234,15 +360,20 @@ ${payload.rawText}''';
Future<String> _callGoogleAI( Future<String> _callGoogleAI(
Dio dio, Dio dio,
String apiKey, String baseUrl,
String? apiKey,
String model,
String systemPrompt, String systemPrompt,
String userPrompt, String userPrompt,
) async { ) async {
final Response<Map<String, dynamic>> final String modelPath = model.startsWith('models/')
response = await dio.post<Map<String, dynamic>>( ? model
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', : 'models/$model';
final Response<Map<String, dynamic>> response = await dio
.post<Map<String, dynamic>>(
'$baseUrl/$modelPath:generateContent',
options: Options(headers: {'Content-Type': 'application/json'}), options: Options(headers: {'Content-Type': 'application/json'}),
queryParameters: {'key': apiKey}, queryParameters: {'key': apiKey ?? ''},
data: jsonEncode({ data: jsonEncode({
'systemInstruction': { 'systemInstruction': {
'parts': [ 'parts': [
@@ -273,6 +404,128 @@ ${payload.rawText}''';
return contentText; 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( List<GeneratedSignal> _parseSignalsResponse(
String response, String response,
List<PersonProfile> people, 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 { class GeneratedSignal {
const GeneratedSignal({ const GeneratedSignal({
required this.title, required this.title,
+4
View File
@@ -4,3 +4,7 @@ This slice owns settings and trust/privacy messaging.
The screen is intentionally simple. This is the right place for local-first The screen is intentionally simple. This is the right place for local-first
copy, future AI-sharing controls, and developer/founder-mode toggles. copy, future AI-sharing controls, and developer/founder-mode toggles.
LLM connection controls live in `presentation/settings_view.dart` and persist
through `core/llm/llm_config.dart`. Provider-specific model discovery and debug
prompt calls are owned by `core/llm/llm_service.dart`.
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart'; import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart'; import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart'; import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
@@ -100,9 +101,7 @@ class SettingsView extends ConsumerWidget {
_SettingRow( _SettingRow(
compact: compact, compact: compact,
label: 'LLM AI', label: 'LLM AI',
value: ref.watch(llmConfigProvider).isConfigured value: _llmStatusLabel(ref.watch(llmConfigProvider)),
? '${_providerLabel(ref.watch(llmConfigProvider).provider)} (configured)'
: 'Not configured',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SettingRow( _SettingRow(
@@ -133,6 +132,11 @@ class SettingsView extends ConsumerWidget {
: 'Setup AI', : 'Setup AI',
), ),
), ),
OutlinedButton.icon(
onPressed: () => _showLlmDebug(context),
icon: const Icon(Icons.terminal_rounded),
label: const Text('LLM Debug'),
),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => _toggleDigest(context, ref), onPressed: () => _toggleDigest(context, ref),
icon: Icon( icon: Icon(
@@ -236,15 +240,11 @@ class SettingsView extends ConsumerWidget {
); );
} }
String _providerLabel(LlmProvider provider) { String _llmStatusLabel(LlmConfigState state) {
switch (provider) { if (!state.isConfigured) {
case LlmProvider.openai: return 'Not configured';
return 'OpenAI';
case LlmProvider.anthropic:
return 'Anthropic';
case LlmProvider.google:
return 'Google AI';
} }
return '${state.provider.label} / ${state.model}';
} }
String _weekdayLabel(int weekday) { String _weekdayLabel(int weekday) {
@@ -266,6 +266,8 @@ class SettingsView extends ConsumerWidget {
context: context, context: context,
builder: (BuildContext context) => _LlmConfigDialog( builder: (BuildContext context) => _LlmConfigDialog(
currentProvider: current.provider, currentProvider: current.provider,
currentBaseUrl: current.baseUrl,
currentModel: current.model,
hasApiKey: current.apiKeyConfigured, hasApiKey: current.apiKeyConfigured,
), ),
); );
@@ -273,20 +275,21 @@ class SettingsView extends ConsumerWidget {
return; return;
} }
await ref.read(llmConfigProvider.notifier).setProvider(draft.provider); await ref
if (draft.clearApiKey) { .read(llmConfigProvider.notifier)
await ref.read(llmConfigProvider.notifier).clearApiKey(); .saveConfiguration(
} else if (draft.apiKey.isNotEmpty) { provider: draft.provider,
await ref.read(llmConfigProvider.notifier).setApiKey(draft.apiKey); baseUrl: draft.baseUrl,
} model: draft.model,
apiKey: draft.apiKey,
clearApiKey: draft.clearApiKey,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
draft.apiKey.isNotEmpty draft.clearApiKey
? 'AI configuration saved.'
: draft.clearApiKey
? 'AI API key cleared.' ? 'AI API key cleared.'
: 'AI configuration saved.', : 'AI configuration saved.',
), ),
@@ -316,6 +319,13 @@ class SettingsView extends ConsumerWidget {
); );
} }
Future<void> _showLlmDebug(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (BuildContext context) => const _LlmDebugDialog(),
);
}
Future<void> _runPrivateDigest(BuildContext context, WidgetRef ref) async { Future<void> _runPrivateDigest(BuildContext context, WidgetRef ref) async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar( messenger.showSnackBar(
@@ -542,94 +552,202 @@ String _debugTimeLabel(DateTime value) {
class _LlmConfigDraft { class _LlmConfigDraft {
const _LlmConfigDraft({ const _LlmConfigDraft({
required this.provider, required this.provider,
required this.apiKey, required this.baseUrl,
required this.model,
this.apiKey,
this.clearApiKey = false, this.clearApiKey = false,
}); });
final LlmProvider provider; final LlmProvider provider;
final String apiKey; final String baseUrl;
final String model;
final String? apiKey;
final bool clearApiKey; final bool clearApiKey;
} }
class _LlmConfigDialog extends StatefulWidget { class _LlmConfigDialog extends ConsumerStatefulWidget {
const _LlmConfigDialog({ const _LlmConfigDialog({
required this.currentProvider, required this.currentProvider,
required this.currentBaseUrl,
required this.currentModel,
required this.hasApiKey, required this.hasApiKey,
}); });
final LlmProvider currentProvider; final LlmProvider currentProvider;
final String currentBaseUrl;
final String currentModel;
final bool hasApiKey; final bool hasApiKey;
@override @override
State<_LlmConfigDialog> createState() => _LlmConfigDialogState(); ConsumerState<_LlmConfigDialog> createState() => _LlmConfigDialogState();
} }
class _LlmConfigDialogState extends State<_LlmConfigDialog> { class _LlmConfigDialogState extends ConsumerState<_LlmConfigDialog> {
late LlmProvider _selectedProvider; late LlmProvider _selectedProvider;
late final TextEditingController _apiKeyController; late final TextEditingController _apiKeyController;
late final TextEditingController _baseUrlController;
late final TextEditingController _modelController;
List<LlmModelInfo> _models = const <LlmModelInfo>[];
bool _loadingModels = false;
String? _modelLoadMessage;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedProvider = widget.currentProvider; _selectedProvider = widget.currentProvider;
_apiKeyController = TextEditingController(); _apiKeyController = TextEditingController();
_baseUrlController = TextEditingController(text: widget.currentBaseUrl);
_modelController = TextEditingController(text: widget.currentModel);
} }
@override @override
void dispose() { void dispose() {
_apiKeyController.dispose(); _apiKeyController.dispose();
_baseUrlController.dispose();
_modelController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool apiKeyVisible =
_selectedProvider.requiresApiKey ||
_selectedProvider == LlmProvider.openaiCompatible;
return AlertDialog( return AlertDialog(
title: const Text('Configure AI Provider'), title: const Text('LLM Connection'),
content: Column( content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
const Text('Provider'),
const SizedBox(height: 8),
DropdownButtonFormField<LlmProvider>( DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider, initialValue: _selectedProvider,
decoration: const InputDecoration(border: OutlineInputBorder()), decoration: const InputDecoration(
items: const <DropdownMenuItem<LlmProvider>>[ labelText: 'Provider',
DropdownMenuItem<LlmProvider>( border: OutlineInputBorder(),
value: LlmProvider.openai,
child: Text('OpenAI'),
), ),
DropdownMenuItem<LlmProvider>( items: LlmProvider.values
value: LlmProvider.anthropic, .map(
child: Text('Anthropic (Claude)'), (LlmProvider provider) => DropdownMenuItem<LlmProvider>(
value: provider,
child: Text(provider.label),
), ),
DropdownMenuItem<LlmProvider>( )
value: LlmProvider.google, .toList(growable: false),
child: Text('Google AI'),
),
],
onChanged: (LlmProvider? value) { onChanged: (LlmProvider? value) {
if (value != null) { if (value == null) {
return;
}
setState(() { setState(() {
_selectedProvider = value; _selectedProvider = value;
_baseUrlController.text = value.defaultBaseUrl;
_modelController.text = value.defaultModel;
_models = const <LlmModelInfo>[];
_modelLoadMessage = null;
}); });
}
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
const Text('API Key'), TextField(
const SizedBox(height: 8), controller: _baseUrlController,
decoration: InputDecoration(
labelText: _selectedProvider.hasConfigurableBaseUrl
? 'Base URL'
: 'Provider endpoint',
helperText: _selectedProvider == LlmProvider.openaiCompatible
? 'Use the OpenAI-compatible API base, usually ending in /v1.'
: null,
border: const OutlineInputBorder(),
),
),
if (apiKeyVisible) ...<Widget>[
const SizedBox(height: 12),
TextField( TextField(
controller: _apiKeyController, controller: _apiKeyController,
obscureText: true, obscureText: true,
decoration: InputDecoration( decoration: InputDecoration(
labelText: _selectedProvider.requiresApiKey
? 'API key'
: 'API key (optional)',
hintText: widget.hasApiKey hintText: widget.hasApiKey
? 'Leave empty to keep current' ? 'Leave empty to keep current key'
: 'Enter your API key', : 'Paste provider key',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
], ],
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: _models.isEmpty
? TextField(
controller: _modelController,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
)
: DropdownButtonFormField<String>(
initialValue: _modelController.text.isNotEmpty
? _modelController.text
: _models.first.id,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
items: _models
.map(
(LlmModelInfo model) =>
DropdownMenuItem<String>(
value: model.id,
child: Text(model.label),
),
)
.toList(growable: false),
onChanged: (String? value) {
if (value != null) {
_modelController.text = value;
}
},
),
),
const SizedBox(width: 8),
IconButton.outlined(
onPressed: _loadingModels ? null : _loadModels,
icon: _loadingModels
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_sync_rounded),
tooltip: 'Load models from provider',
),
],
),
if (_modelLoadMessage != null) ...<Widget>[
const SizedBox(height: 8),
Text(
_modelLoadMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
],
const SizedBox(height: 12),
Text(
_selectedProvider == LlmProvider.ollama
? 'Ollama reads installed local models from /api/tags and sends chat requests to /api/chat.'
: 'Model discovery uses this provider connection before saving the selected model.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
@@ -642,7 +760,8 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> {
Navigator.of(context).pop( Navigator.of(context).pop(
_LlmConfigDraft( _LlmConfigDraft(
provider: _selectedProvider, provider: _selectedProvider,
apiKey: '', baseUrl: _baseUrlController.text.trim(),
model: _modelController.text.trim(),
clearApiKey: true, clearApiKey: true,
), ),
); );
@@ -654,7 +773,11 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> {
Navigator.of(context).pop( Navigator.of(context).pop(
_LlmConfigDraft( _LlmConfigDraft(
provider: _selectedProvider, provider: _selectedProvider,
apiKey: _apiKeyController.text.trim(), baseUrl: _baseUrlController.text.trim(),
model: _modelController.text.trim(),
apiKey: _apiKeyController.text.trim().isEmpty
? null
: _apiKeyController.text.trim(),
), ),
); );
}, },
@@ -663,6 +786,211 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> {
], ],
); );
} }
Future<void> _loadModels() async {
setState(() {
_loadingModels = true;
_modelLoadMessage = null;
});
try {
final List<LlmModelInfo> models = await ref
.read(llmServiceProvider)
.listAvailableModels(
provider: _selectedProvider,
apiKey: _apiKeyController.text.trim().isEmpty
? null
: _apiKeyController.text.trim(),
baseUrl: _baseUrlController.text.trim(),
);
if (!mounted) {
return;
}
setState(() {
_models = models;
if (models.isNotEmpty &&
!_models.any(
(LlmModelInfo model) => model.id == _modelController.text,
)) {
_modelController.text = models.first.id;
}
_modelLoadMessage = models.isEmpty
? 'No models were returned by this provider.'
: 'Loaded ${models.length} models.';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_modelLoadMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loadingModels = false;
});
}
}
}
}
class _LlmDebugDialog extends ConsumerStatefulWidget {
const _LlmDebugDialog();
@override
ConsumerState<_LlmDebugDialog> createState() => _LlmDebugDialogState();
}
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
late final TextEditingController _promptController;
bool _running = false;
String? _response;
String? _error;
@override
void initState() {
super.initState();
_promptController = TextEditingController(
text: 'Reply with one short sentence confirming the connection works.',
);
}
@override
void dispose() {
_promptController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final LlmConfigState config = ref.watch(llmConfigProvider);
return AlertDialog(
title: const Text('LLM Debug'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_LlmDebugConnectionSummary(config: config),
const SizedBox(height: 12),
TextField(
controller: _promptController,
minLines: 3,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Test prompt',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
if (_running) const LinearProgressIndicator(),
if (_error != null) ...<Widget>[
const SizedBox(height: 12),
SelectableText(
_error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
if (_response != null) ...<Widget>[
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Align(
alignment: Alignment.centerLeft,
child: SelectableText(_response!),
),
),
),
],
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: _running ? null : () => Navigator.of(context).pop(),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: !_running && config.isConfigured ? _runDebugPrompt : null,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Send Test'),
),
],
);
}
Future<void> _runDebugPrompt() async {
setState(() {
_running = true;
_response = null;
_error = null;
});
try {
final String response = await ref
.read(llmServiceProvider)
.completeText(
systemPrompt:
'You are a connectivity probe for a relationship app. Answer briefly.',
userPrompt: _promptController.text.trim(),
);
if (!mounted) {
return;
}
setState(() {
_response = response;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_error = error.toString();
});
} finally {
if (mounted) {
setState(() {
_running = false;
});
}
}
}
}
class _LlmDebugConnectionSummary extends StatelessWidget {
const _LlmDebugConnectionSummary({required this.config});
final LlmConfigState config;
@override
Widget build(BuildContext context) {
final TextStyle? labelStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(config.isConfigured ? 'Configured' : 'Not configured'),
const SizedBox(height: 6),
Text('Provider: ${config.provider.label}', style: labelStyle),
Text(
'Model: ${config.model.isEmpty ? 'Not selected' : config.model}',
style: labelStyle,
),
Text('Endpoint: ${config.baseUrl}', style: labelStyle),
],
);
}
} }
class _ShareSimulationDraft { class _ShareSimulationDraft {
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
void main() {
test('cloud provider defaults need an API key', () {
final LlmConfigState missingKey = LlmConfigState(
provider: LlmProvider.openai,
);
final LlmConfigState configured = LlmConfigState(
apiKeyConfigured: true,
provider: LlmProvider.openai,
);
expect(missingKey.isConfigured, isFalse);
expect(configured.isConfigured, isTrue);
expect(configured.baseUrl, 'https://api.openai.com/v1');
expect(configured.model, 'gpt-4o-mini');
});
test('local providers can be configured without an API key', () {
final LlmConfigState ollama = LlmConfigState(
provider: LlmProvider.ollama,
baseUrl: 'http://192.168.1.12:11434',
model: 'llama3.2:latest',
);
final LlmConfigState compatible = LlmConfigState(
provider: LlmProvider.openaiCompatible,
baseUrl: 'http://localhost:8000/v1',
model: 'local-model',
);
expect(ollama.isConfigured, isTrue);
expect(compatible.isConfigured, isTrue);
});
test('local providers still require a model and endpoint', () {
final LlmConfigState missingModel = LlmConfigState(
provider: LlmProvider.ollama,
baseUrl: 'http://localhost:11434',
);
final LlmConfigState missingEndpoint = LlmConfigState(
provider: LlmProvider.openaiCompatible,
model: 'local-model',
);
expect(missingModel.isConfigured, isFalse);
expect(missingEndpoint.isConfigured, isFalse);
});
}