@tduncan / Cortana / commits / 8d36a75

feat: improve server connection reliability by adding multi-endpoint validation and automated connectivity checks

abdulmominsakib committed Aug 1, 2026 at 22:42 UTC 8d36a75b059c7a261ce8a32cd2660fad50ae87da
6 files changed +131 -40
.gitignore
+2 -1
@@ -55,4 +55,5 @@ notes.txt
55 docs/raw_ss/
56 docs/marketing/
57 .puku-cli/
58 -.puku/
\ No newline at end of file
58 +.puku/
59 +.opencode/
\ No newline at end of file
lib/features/servers/data/server_api_service.dart
+53 -15
@@ -11,18 +11,43 @@ class ServerApiService {
11 ServerApiService(this._dio);
12
13 Future<bool> testConnection(Server server) async {
14 - try {
15 - final response = await _dio.get(
16 - server.modelsEndpoint,
17 - options: Options(
18 - headers: buildServerAuthHeaders(server),
19 - validateStatus: (status) => status != null && status < 500,
20 - ),
21 - );
22 - return response.statusCode == 200;
23 - } catch (e) {
24 - return false;
14 + if (server.type == ServerType.onDevice) {
15 + return true;
16 + }
17 +
18 + final endpointsToTry = <String>[];
19 + if (server.modelsEndpoint.isNotEmpty) {
20 + endpointsToTry.add(server.modelsEndpoint);
21 + }
22 +
23 + if (server.type == ServerType.lmStudio) {
24 + endpointsToTry.add('${server.baseUrl}/v1/models');
25 + endpointsToTry.add('${server.baseUrl}/api/v0/models');
26 + } else if (server.type == ServerType.openAICompatible) {
27 + endpointsToTry.add('${server.baseUrl}/models');
28 + } else if (server.type == ServerType.ollama) {
29 + endpointsToTry.add('${server.baseUrl}/api/tags');
30 + endpointsToTry.add('${server.baseUrl}/api/version');
31 + }
32 +
33 + for (final endpoint in endpointsToTry.toSet()) {
34 + if (endpoint.isEmpty) continue;
35 + try {
36 + final response = await _dio.get(
37 + endpoint,
38 + options: Options(
39 + headers: buildServerAuthHeaders(server),
40 + validateStatus: (status) => status != null && status < 500,
41 + ),
42 + );
43 + if (response.statusCode == 200) {
44 + return true;
45 + }
46 + } catch (_) {
47 + // Try next endpoint
48 + }
49 }
50 + return false;
51 }
52
53 Future<int?> pingServer(Server server) async {
@@ -41,10 +66,23 @@ class ServerApiService {
66
67 Future<List<ModelInfo>> fetchModels(Server server) async {
68 try {
44 - final response = await _dio.get(
45 - server.modelsEndpoint,
46 - options: Options(headers: buildServerAuthHeaders(server)),
47 - );
69 + Response response;
70 + try {
71 + response = await _dio.get(
72 + server.modelsEndpoint,
73 + options: Options(headers: buildServerAuthHeaders(server)),
74 + );
75 + } on DioException catch (e) {
76 + if (e.response?.statusCode == 404 &&
77 + server.type == ServerType.lmStudio) {
78 + response = await _dio.get(
79 + '${server.baseUrl}/v1/models',
80 + options: Options(headers: buildServerAuthHeaders(server)),
81 + );
82 + } else {
83 + rethrow;
84 + }
85 + }
86
87 List<ModelInfo> models;
88 switch (server.type) {
lib/features/servers/providers/server_providers.dart
+49 -10
@@ -200,20 +200,21 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
200 state = AsyncData(await _loadAll());
201 }
202
203 - Future<ConnectionStatus> testConnection(
203 + Future<void> updateServerStatus(
204 String serverId,
205 - dynamic apiService,
205 + ConnectionStatus status,
206 ) async {
207 final servers = state.value ?? [];
208 - final server = servers.firstWhere((s) => s.id == serverId);
209 - final isConnected = await apiService.testConnection(server);
210 - final status = isConnected
211 - ? ConnectionStatus.connected
212 - : ConnectionStatus.error;
208 + final index = servers.indexWhere((s) => s.id == serverId);
209 + if (index == -1) return;
210 + final server = servers[index];
211 + if (server.status == status) return;
212
213 final updatedServer = server.copyWith(
214 status: status,
216 - lastConnectedAt: DateTime.now(),
215 + lastConnectedAt: status == ConnectionStatus.connected
216 + ? DateTime.now()
217 + : server.lastConnectedAt,
218 );
219 final db = ref.read(databaseProvider);
220
@@ -231,8 +232,37 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
232 invalidateAvailableModelsCache(updatedServer.id);
233
234 state = AsyncData(await _loadAll());
235 + }
236 +
237 + Future<ConnectionStatus> testConnection(
238 + String serverId,
239 + dynamic apiService,
240 + ) async {
241 + final servers = state.value ?? [];
242 + final index = servers.indexWhere((s) => s.id == serverId);
243 + if (index == -1) return ConnectionStatus.disconnected;
244 + final server = servers[index];
245 +
246 + if (server.type == ServerType.onDevice) {
247 + await updateServerStatus(serverId, ConnectionStatus.connected);
248 + return ConnectionStatus.connected;
249 + }
250 +
251 + final isConnected = await apiService.testConnection(server);
252 + final status = isConnected
253 + ? ConnectionStatus.connected
254 + : ConnectionStatus.disconnected;
255 +
256 + await updateServerStatus(serverId, status);
257 return status;
258 }
259 +
260 + Future<void> testAllConnections(dynamic apiService) async {
261 + final servers = state.value ?? [];
262 + for (final server in servers) {
263 + await testConnection(server.id, apiService);
264 + }
265 + }
266 }
267
268 class ActiveServerNotifier extends Notifier<Server?> {
@@ -272,7 +302,7 @@ class ActiveServerNotifier extends Notifier<Server?> {
302 servers,
303 currentId ?? prefs.getString('defaultServerId'),
304 );
275 - if (resolved?.id != state?.id) {
305 + if (resolved?.id != state?.id || resolved?.status != state?.status) {
306 state = resolved;
307 }
308 }
@@ -309,9 +339,18 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
339 Future<void> _checkConnection(Server server, dynamic apiService) async {
340 try {
341 final isConnected = await apiService.testConnection(server);
312 - state = isConnected ? ConnectionStatus.connected : ConnectionStatus.error;
342 + final status = isConnected
343 + ? ConnectionStatus.connected
344 + : ConnectionStatus.disconnected;
345 + state = status;
346 + ref
347 + .read(serversProvider.notifier)
348 + .updateServerStatus(server.id, status);
349 } catch (e) {
350 state = ConnectionStatus.error;
351 + ref
352 + .read(serversProvider.notifier)
353 + .updateServerStatus(server.id, ConnectionStatus.disconnected);
354 }
355 }
356
lib/features/servers/views/add_server_screen.dart
+5 -1
@@ -166,7 +166,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
166 isDefault: widget.editServer?.isDefault ?? false,
167 createdAt: widget.editServer?.createdAt ?? DateTime.now(),
168 lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
169 - status: ConnectionStatus.disconnected,
169 + status: widget.editServer?.status ?? ConnectionStatus.disconnected,
170 iconName: _selectedIconName,
171 availableRamGb: _selectedType == ServerType.lmStudio
172 ? _parseOptionalGb(_ramGbController.text)
@@ -192,6 +192,10 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
192 } else {
193 await ref.read(serversProvider.notifier).addServer(server);
194 }
195 + final apiService = ref.read(serverApiServiceProvider);
196 + await ref
197 + .read(serversProvider.notifier)
198 + .testConnection(server.id, apiService);
199 invalidateAvailableModelsCache(server.id);
200
201 if (mounted) {
lib/features/servers/views/server_list_screen.dart
+21 -12
@@ -10,11 +10,29 @@ import '../../../core/utils/system_insets.dart';
10 import '../providers/server_providers.dart';
11 import 'components/server_card.dart';
12
13 -class ServerListScreen extends ConsumerWidget {
13 +class ServerListScreen extends ConsumerStatefulWidget {
14 const ServerListScreen({super.key});
15
16 @override
17 - Widget build(BuildContext context, WidgetRef ref) {
17 + ConsumerState<ServerListScreen> createState() => _ServerListScreenState();
18 +}
19 +
20 +class _ServerListScreenState extends ConsumerState<ServerListScreen> {
21 + @override
22 + void initState() {
23 + super.initState();
24 + WidgetsBinding.instance.addPostFrameCallback((_) {
25 + _checkAllConnections();
26 + });
27 + }
28 +
29 + Future<void> _checkAllConnections() async {
30 + final apiService = ref.read(serverApiServiceProvider);
31 + await ref.read(serversProvider.notifier).testAllConnections(apiService);
32 + }
33 +
34 + @override
35 + Widget build(BuildContext context) {
36 final l10n = AppLocalizations.of(context)!;
37 final servers = ref.watch(serversProvider);
38 final activeServer = ref.watch(activeServerProvider);
@@ -72,16 +90,7 @@ class ServerListScreen extends ConsumerWidget {
90 data: (serverList) => serverList.isEmpty
91 ? _buildEmptyState(context, l10n, theme)
92 : RefreshIndicator(
75 - onRefresh: () async {
76 - for (final server in serverList) {
77 - await ref
78 - .read(serversProvider.notifier)
79 - .testConnection(
80 - server.id,
81 - ref.read(serverApiServiceProvider),
82 - );
83 - }
84 - },
93 + onRefresh: _checkAllConnections,
94 child: ListView.builder(
95 padding: EdgeInsets.fromLTRB(
96 16,
pubspec.yaml
+1 -1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.5.8+63
19 +version: 1.5.9+64
20
21 environment:
22 sdk: ">=3.12.0 <4.0.0"