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';
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
state = LlmConfigState(
|
||||||
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
|
apiKeyConfigured: storedKey != null && storedKey.isNotEmpty,
|
||||||
provider: storedProvider != null
|
provider: provider,
|
||||||
? LlmProvider.values.firstWhere(
|
baseUrl: storedBaseUrl,
|
||||||
(LlmProvider p) => p.name == storedProvider,
|
model: storedModel,
|
||||||
orElse: () => LlmProvider.openai,
|
|
||||||
)
|
|
||||||
: LlmProvider.openai,
|
|
||||||
isConfigured: storedKey != null && storedKey.isNotEmpty,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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/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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (provider) {
|
try {
|
||||||
case LlmProvider.openai:
|
switch (provider) {
|
||||||
return _callOpenAI(dio, apiKey, systemPrompt, userPrompt);
|
case LlmProvider.openai:
|
||||||
case LlmProvider.anthropic:
|
return _callOpenAI(
|
||||||
return _callAnthropic(dio, apiKey, systemPrompt, userPrompt);
|
dio,
|
||||||
case LlmProvider.google:
|
resolvedBaseUrl,
|
||||||
return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt);
|
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(
|
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,31 +360,36 @@ ${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';
|
||||||
options: Options(headers: {'Content-Type': 'application/json'}),
|
final Response<Map<String, dynamic>> response = await dio
|
||||||
queryParameters: {'key': apiKey},
|
.post<Map<String, dynamic>>(
|
||||||
data: jsonEncode({
|
'$baseUrl/$modelPath:generateContent',
|
||||||
'systemInstruction': {
|
options: Options(headers: {'Content-Type': 'application/json'}),
|
||||||
'parts': [
|
queryParameters: {'key': apiKey ?? ''},
|
||||||
{'text': systemPrompt},
|
data: jsonEncode({
|
||||||
],
|
'systemInstruction': {
|
||||||
},
|
'parts': [
|
||||||
'contents': [
|
{'text': systemPrompt},
|
||||||
{
|
],
|
||||||
'parts': [
|
},
|
||||||
{'text': userPrompt},
|
'contents': [
|
||||||
|
{
|
||||||
|
'parts': [
|
||||||
|
{'text': userPrompt},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
||||||
],
|
}),
|
||||||
'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000},
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Map<String, dynamic> responseData = response.data!;
|
final Map<String, dynamic> responseData = response.data!;
|
||||||
final List<dynamic> candidates =
|
final List<dynamic> candidates =
|
||||||
@@ -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,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(
|
||||||
mainAxisSize: MainAxisSize.min,
|
width: 560,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: <Widget>[
|
child: Column(
|
||||||
const Text('Provider'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
DropdownButtonFormField<LlmProvider>(
|
children: <Widget>[
|
||||||
initialValue: _selectedProvider,
|
DropdownButtonFormField<LlmProvider>(
|
||||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
initialValue: _selectedProvider,
|
||||||
items: const <DropdownMenuItem<LlmProvider>>[
|
decoration: const InputDecoration(
|
||||||
DropdownMenuItem<LlmProvider>(
|
labelText: 'Provider',
|
||||||
value: LlmProvider.openai,
|
border: OutlineInputBorder(),
|
||||||
child: Text('OpenAI'),
|
),
|
||||||
|
items: LlmProvider.values
|
||||||
|
.map(
|
||||||
|
(LlmProvider provider) => DropdownMenuItem<LlmProvider>(
|
||||||
|
value: provider,
|
||||||
|
child: Text(provider.label),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: (LlmProvider? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_selectedProvider = value;
|
||||||
|
_baseUrlController.text = value.defaultBaseUrl;
|
||||||
|
_modelController.text = value.defaultModel;
|
||||||
|
_models = const <LlmModelInfo>[];
|
||||||
|
_modelLoadMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DropdownMenuItem<LlmProvider>(
|
const SizedBox(height: 12),
|
||||||
value: LlmProvider.anthropic,
|
TextField(
|
||||||
child: Text('Anthropic (Claude)'),
|
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(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem<LlmProvider>(
|
if (apiKeyVisible) ...<Widget>[
|
||||||
value: LlmProvider.google,
|
const SizedBox(height: 12),
|
||||||
child: Text('Google AI'),
|
TextField(
|
||||||
|
controller: _apiKeyController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _selectedProvider.requiresApiKey
|
||||||
|
? 'API key'
|
||||||
|
: 'API key (optional)',
|
||||||
|
hintText: widget.hasApiKey
|
||||||
|
? 'Leave empty to keep current key'
|
||||||
|
: 'Paste provider key',
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (LlmProvider? value) {
|
|
||||||
if (value != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedProvider = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
const Text('API Key'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(
|
|
||||||
controller: _apiKeyController,
|
|
||||||
obscureText: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: widget.hasApiKey
|
|
||||||
? 'Leave empty to keep current'
|
|
||||||
: 'Enter your API key',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user