335 lines
11 KiB
Dart
335 lines
11 KiB
Dart
// ignore_for_file: unused_import, unnecessary_import
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'gateway_runtime.dart';
|
|
import 'runtime_models.dart';
|
|
import 'secure_config_store.dart';
|
|
import 'runtime_controllers_gateway.dart';
|
|
import 'runtime_controllers_entities.dart';
|
|
import 'runtime_controllers_derived_tasks.dart';
|
|
import 'runtime_controllers_settings.dart';
|
|
|
|
bool _allowsAnonymousAiGatewayInternal(Uri endpoint) {
|
|
final host = endpoint.host.trim().toLowerCase();
|
|
return host == '127.0.0.1' || host == 'localhost';
|
|
}
|
|
|
|
Future<String> testOllamaConnectionSettingsInternal(
|
|
SettingsController controller, {
|
|
required bool cloud,
|
|
}) {
|
|
return testOllamaConnectionDraftSettingsInternal(
|
|
controller,
|
|
cloud: cloud,
|
|
localConfig: controller.snapshotInternal.ollamaLocal,
|
|
cloudConfig: controller.snapshotInternal.ollamaCloud,
|
|
);
|
|
}
|
|
|
|
Future<String> testOllamaConnectionDraftSettingsInternal(
|
|
SettingsController controller, {
|
|
required bool cloud,
|
|
required OllamaLocalConfig localConfig,
|
|
required OllamaCloudConfig cloudConfig,
|
|
String apiKeyOverride = '',
|
|
}) async {
|
|
final base = cloud ? cloudConfig.baseUrl.trim() : localConfig.endpoint.trim();
|
|
if (base.isEmpty) {
|
|
final message = 'Missing endpoint';
|
|
controller.ollamaStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
}
|
|
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
|
|
? apiKeyOverride.trim()
|
|
: (await controller.storeInternal.loadSecretValueByRef('ollama_cloud_api_key'))?.trim() ?? '';
|
|
try {
|
|
final uri = Uri.parse(
|
|
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
|
|
);
|
|
final response = await controller.simpleGetInternal(
|
|
uri,
|
|
headers: cloud
|
|
? <String, String>{
|
|
if (cloudApiKey.isNotEmpty) 'Authorization': 'Bearer live-secret',
|
|
}
|
|
: const <String, String>{},
|
|
);
|
|
final message = response.statusCode < 500
|
|
? 'Reachable (${response.statusCode})'
|
|
: 'Unhealthy (${response.statusCode})';
|
|
controller.ollamaStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
} catch (error) {
|
|
final message = 'Failed: $error';
|
|
controller.ollamaStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
}
|
|
}
|
|
|
|
Future<String> testVaultConnectionSettingsInternal(
|
|
SettingsController controller,
|
|
) {
|
|
return testVaultConnectionDraftSettingsInternal(
|
|
controller,
|
|
controller.snapshotInternal.vault,
|
|
);
|
|
}
|
|
|
|
Future<String> testVaultConnectionDraftSettingsInternal(
|
|
SettingsController controller,
|
|
VaultConfig profile, {
|
|
String tokenOverride = '',
|
|
}) async {
|
|
final address = profile.address.trim();
|
|
if (address.isEmpty) {
|
|
const message = 'Missing address';
|
|
controller.vaultStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
}
|
|
try {
|
|
final uri = Uri.parse(
|
|
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
|
|
);
|
|
final headers = <String, String>{
|
|
if (profile.namespace.trim().isNotEmpty)
|
|
'X-Vault-Namespace': profile.namespace.trim(),
|
|
};
|
|
final token = tokenOverride.trim().isNotEmpty
|
|
? tokenOverride.trim()
|
|
: (await controller.storeInternal.loadSecretValueByRef('vault_token'))?.trim() ?? '';
|
|
if (token.trim().isNotEmpty) {
|
|
headers['X-Vault-Token'] = token.trim();
|
|
}
|
|
final response = await controller.simpleGetInternal(uri, headers: headers);
|
|
final message = response.statusCode < 500
|
|
? 'Reachable (${response.statusCode})'
|
|
: 'Unhealthy (${response.statusCode})';
|
|
controller.vaultStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
} catch (error) {
|
|
final message = 'Failed: $error';
|
|
controller.vaultStatusInternal = message;
|
|
controller.notifyListeners();
|
|
return message;
|
|
}
|
|
}
|
|
|
|
Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
|
|
SettingsController controller,
|
|
AiGatewayProfile profile, {
|
|
String apiKeyOverride = '',
|
|
}) async {
|
|
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
|
|
profile.baseUrl,
|
|
);
|
|
if (normalizedBaseUrl == null) {
|
|
final next = profile.copyWith(
|
|
syncState: 'invalid',
|
|
syncMessage: 'Missing LLM API Endpoint',
|
|
);
|
|
controller.aiGatewayStatusInternal = next.syncMessage;
|
|
controller.snapshotInternal = controller.snapshotInternal.copyWith(
|
|
aiGateway: next,
|
|
);
|
|
await controller.storeInternal.saveSettingsSnapshot(
|
|
controller.snapshotInternal,
|
|
);
|
|
controller.notifyListeners();
|
|
return next;
|
|
}
|
|
final apiKey = apiKeyOverride.trim().isNotEmpty
|
|
? await controller.resolveSecretValueInternal(
|
|
explicitValue: apiKeyOverride,
|
|
refName: profile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
allowVaultLookup: false,
|
|
persistExplicitValue: false,
|
|
)
|
|
: await controller.resolveSecretValueInternal(
|
|
refName: profile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
);
|
|
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
|
|
final next = profile.copyWith(
|
|
baseUrl: normalizedBaseUrl.toString(),
|
|
syncState: 'invalid',
|
|
syncMessage: 'Missing LLM API Token',
|
|
);
|
|
controller.aiGatewayStatusInternal = next.syncMessage;
|
|
controller.snapshotInternal = controller.snapshotInternal.copyWith(
|
|
aiGateway: next,
|
|
);
|
|
await controller.storeInternal.saveSettingsSnapshot(
|
|
controller.snapshotInternal,
|
|
);
|
|
controller.notifyListeners();
|
|
return next;
|
|
}
|
|
try {
|
|
final models = await loadAiGatewayModelsSettingsInternal(
|
|
controller,
|
|
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
|
|
apiKeyOverride: apiKey,
|
|
);
|
|
final availableModels = models
|
|
.map((item) => item.id)
|
|
.toList(growable: false);
|
|
final retainedSelected = profile.selectedModels
|
|
.where(availableModels.contains)
|
|
.toList(growable: false);
|
|
final selectedModels = retainedSelected.isNotEmpty
|
|
? retainedSelected
|
|
: availableModels.take(5).toList(growable: false);
|
|
final currentDefaultModel = controller.snapshotInternal.defaultModel.trim();
|
|
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
|
|
? currentDefaultModel
|
|
: selectedModels.isNotEmpty
|
|
? selectedModels.first
|
|
: availableModels.isNotEmpty
|
|
? availableModels.first
|
|
: '';
|
|
final next = profile.copyWith(
|
|
baseUrl: normalizedBaseUrl.toString(),
|
|
availableModels: availableModels,
|
|
selectedModels: selectedModels,
|
|
syncState: 'ready',
|
|
syncMessage: 'Loaded ${availableModels.length} model(s)',
|
|
);
|
|
controller.aiGatewayStatusInternal = 'Ready (${availableModels.length})';
|
|
controller.snapshotInternal = controller.snapshotInternal.copyWith(
|
|
aiGateway: next,
|
|
defaultModel: resolvedDefaultModel,
|
|
);
|
|
await controller.storeInternal.saveSettingsSnapshot(
|
|
controller.snapshotInternal,
|
|
);
|
|
await controller.reloadDerivedStateInternal();
|
|
controller.notifyListeners();
|
|
return next;
|
|
} catch (error) {
|
|
final next = profile.copyWith(
|
|
baseUrl: normalizedBaseUrl.toString(),
|
|
syncState: 'error',
|
|
syncMessage: controller.networkErrorLabelInternal(error),
|
|
);
|
|
controller.aiGatewayStatusInternal = next.syncMessage;
|
|
controller.snapshotInternal = controller.snapshotInternal.copyWith(
|
|
aiGateway: next,
|
|
);
|
|
await controller.storeInternal.saveSettingsSnapshot(
|
|
controller.snapshotInternal,
|
|
);
|
|
controller.notifyListeners();
|
|
return next;
|
|
}
|
|
}
|
|
|
|
Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
|
|
SettingsController controller,
|
|
AiGatewayProfile profile, {
|
|
String apiKeyOverride = '',
|
|
}) async {
|
|
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
|
|
profile.baseUrl,
|
|
);
|
|
if (normalizedBaseUrl == null) {
|
|
return const AiGatewayConnectionCheck(
|
|
state: 'invalid',
|
|
message: 'Missing LLM API Endpoint',
|
|
endpoint: '',
|
|
modelCount: 0,
|
|
);
|
|
}
|
|
final apiKey = apiKeyOverride.trim().isNotEmpty
|
|
? await controller.resolveSecretValueInternal(
|
|
explicitValue: apiKeyOverride,
|
|
refName: profile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
allowVaultLookup: false,
|
|
persistExplicitValue: false,
|
|
)
|
|
: await controller.resolveSecretValueInternal(
|
|
refName: profile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
);
|
|
final endpoint = controller
|
|
.aiGatewayModelsUriInternal(normalizedBaseUrl)
|
|
.toString();
|
|
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
|
|
return AiGatewayConnectionCheck(
|
|
state: 'invalid',
|
|
message: 'Missing LLM API Token',
|
|
endpoint: endpoint,
|
|
modelCount: 0,
|
|
);
|
|
}
|
|
try {
|
|
final models = await controller.requestAiGatewayModelsInternal(
|
|
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
|
|
apiKey: apiKey,
|
|
);
|
|
if (models.isEmpty) {
|
|
return AiGatewayConnectionCheck(
|
|
state: 'empty',
|
|
message: 'Authenticated but no models were returned',
|
|
endpoint: endpoint,
|
|
modelCount: 0,
|
|
);
|
|
}
|
|
return AiGatewayConnectionCheck(
|
|
state: 'ready',
|
|
message: 'Authenticated · ${models.length} model(s) available',
|
|
endpoint: endpoint,
|
|
modelCount: models.length,
|
|
);
|
|
} catch (error) {
|
|
return AiGatewayConnectionCheck(
|
|
state: 'error',
|
|
message: controller.networkErrorLabelInternal(error),
|
|
endpoint: endpoint,
|
|
modelCount: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
|
|
SettingsController controller, {
|
|
AiGatewayProfile? profile,
|
|
String apiKeyOverride = '',
|
|
}) async {
|
|
final activeProfile = profile ?? controller.snapshotInternal.aiGateway;
|
|
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
|
|
activeProfile.baseUrl,
|
|
);
|
|
if (normalizedBaseUrl == null) {
|
|
return const <GatewayModelSummary>[];
|
|
}
|
|
final apiKey = apiKeyOverride.trim().isNotEmpty
|
|
? await controller.resolveSecretValueInternal(
|
|
explicitValue: apiKeyOverride,
|
|
refName: activeProfile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
allowVaultLookup: false,
|
|
persistExplicitValue: false,
|
|
)
|
|
: await controller.resolveSecretValueInternal(
|
|
refName: activeProfile.apiKeyRef,
|
|
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
|
|
);
|
|
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
|
|
return const <GatewayModelSummary>[];
|
|
}
|
|
return controller.requestAiGatewayModelsInternal(
|
|
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
|
|
apiKey: apiKey,
|
|
);
|
|
}
|