Improve LLM provider configuration
This commit is contained in:
+215
-31
@@ -2,32 +2,119 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const String _llmApiKeyStorageKey = 'llm_api_key';
|
||||
const String _llmBaseUrlStorageKey = 'llm_base_url';
|
||||
const String _llmModelStorageKey = 'llm_model';
|
||||
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 {
|
||||
const LlmConfigState({
|
||||
this.apiKeyConfigured = false,
|
||||
this.provider = LlmProvider.openai,
|
||||
this.isConfigured = false,
|
||||
LlmConfigState({
|
||||
bool apiKeyConfigured = false,
|
||||
LlmProvider provider = LlmProvider.openai,
|
||||
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 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({
|
||||
bool? apiKeyConfigured,
|
||||
LlmProvider? provider,
|
||||
bool? isConfigured,
|
||||
String? baseUrl,
|
||||
String? model,
|
||||
}) {
|
||||
final LlmProvider nextProvider = provider ?? this.provider;
|
||||
return LlmConfigState(
|
||||
apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured,
|
||||
provider: provider ?? this.provider,
|
||||
isConfigured: isConfigured ?? this.isConfigured,
|
||||
provider: nextProvider,
|
||||
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> {
|
||||
@@ -35,7 +122,7 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
|
||||
|
||||
@override
|
||||
LlmConfigState build() {
|
||||
return const LlmConfigState();
|
||||
return LlmConfigState();
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
@@ -49,36 +136,53 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
|
||||
|
||||
Future<void> _initializeFromStorage() async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
final String? storedKey = await secureStorage.read(
|
||||
key: _llmApiKeyStorageKey,
|
||||
);
|
||||
final String? storedProvider = await secureStorage.read(
|
||||
key: _llmProviderStorageKey,
|
||||
);
|
||||
final String? storedBaseUrl = await secureStorage.read(
|
||||
key: _llmBaseUrlStorageKey,
|
||||
);
|
||||
final String? storedModel = await secureStorage.read(
|
||||
key: _llmModelStorageKey,
|
||||
);
|
||||
|
||||
final LlmProvider provider = storedProvider != null
|
||||
? LlmProvider.values.firstWhere(
|
||||
(LlmProvider p) => p.name == storedProvider,
|
||||
orElse: () => LlmProvider.openai,
|
||||
)
|
||||
: LlmProvider.openai;
|
||||
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: storedProvider != null
|
||||
? LlmProvider.values.firstWhere(
|
||||
(LlmProvider p) => p.name == storedProvider,
|
||||
orElse: () => LlmProvider.openai,
|
||||
)
|
||||
: LlmProvider.openai,
|
||||
isConfigured: storedKey != null && storedKey.isNotEmpty,
|
||||
provider: provider,
|
||||
baseUrl: storedBaseUrl,
|
||||
model: storedModel,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setApiKey(String apiKey) async {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
final String key = _providerApiKeyStorageKey(state.provider);
|
||||
if (apiKey.isEmpty) {
|
||||
await secureStorage.delete(key: _llmApiKeyStorageKey);
|
||||
await secureStorage.delete(key: key);
|
||||
} else {
|
||||
await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey);
|
||||
await secureStorage.write(key: key, value: apiKey);
|
||||
}
|
||||
state = state.copyWith(
|
||||
apiKeyConfigured: apiKey.isNotEmpty,
|
||||
isConfigured: apiKey.isNotEmpty,
|
||||
);
|
||||
state = state.copyWith(apiKeyConfigured: apiKey.isNotEmpty);
|
||||
}
|
||||
|
||||
Future<void> setProvider(LlmProvider provider) async {
|
||||
@@ -87,18 +191,98 @@ class LlmConfigNotifier extends Notifier<LlmConfigState> {
|
||||
key: _llmProviderStorageKey,
|
||||
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();
|
||||
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 {
|
||||
const FlutterSecureStorage secureStorage = FlutterSecureStorage();
|
||||
await secureStorage.delete(key: _llmApiKeyStorageKey);
|
||||
state = state.copyWith(apiKeyConfigured: false, isConfigured: false);
|
||||
await secureStorage.delete(key: _providerApiKeyStorageKey(state.provider));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+413
-52
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user