@tduncan / Cortana / commits / 7f71519

feat: implement chat error formatter to handle and humanize API error messages and fix chat stream state management

abdulmominsakib committed Jul 27, 2026 at 13:00 UTC 7f715190ee04d465aea5593d8b29f532184301e8
10 files changed +828 -63
lib/features/chat/data/chat_api_error.dart
+27 -2
@@ -19,12 +19,37 @@ class ChatApiError {
19 (code?.isNotEmpty ?? false) ||
20 (param?.isNotEmpty ?? false);
21
22 - String encode() => jsonEncode({
22 + String encode() => jsonEncode(toMap());
23 +
24 + Map<String, dynamic> toMap() => {
25 'message': message,
26 if (type != null && type!.isNotEmpty) 'type': type,
27 if (code != null && code!.isNotEmpty) 'code': code,
28 if (param != null && param!.isNotEmpty) 'param': param,
27 - });
29 + };
30 +
31 + ChatApiError copyWithCodeIfMissing(String? newCode) {
32 + if (code != null && code!.isNotEmpty) return this;
33 + return ChatApiError(
34 + message: message,
35 + type: type,
36 + code: newCode,
37 + param: param,
38 + );
39 + }
40 +
41 + ChatApiError copyWithTypeIfMissing(String? newType) {
42 + if (type != null && type!.isNotEmpty) return this;
43 + return ChatApiError(
44 + message: message,
45 + type: newType,
46 + code: code,
47 + param: param,
48 + );
49 + }
50 +
51 + ChatApiError copyWithMessage(String newMessage) =>
52 + ChatApiError(message: newMessage, type: type, code: code, param: param);
53
54 static ChatApiError? tryParse(String? raw) {
55 if (raw == null || raw.isEmpty) return null;
lib/features/chat/data/chat_error_formatter.dart new
+286
@@ -0,0 +1,286 @@
1 +import 'dart:convert';
2 +
3 +import 'chat_api_error.dart';
4 +
5 +/// Best-effort mapping for the most common chat API failure modes into the
6 +/// `ChatApiError` JSON shape rendered by `ChatErrorDisplay`.
7 +///
8 +/// The same shape works whether the upstream is Ollama, LM Studio,
9 +/// OpenAI-compatible, OpenRouter, or a remote service. Anything that can't be
10 +/// confidently identified falls back to a plain `message` so the bubble still
11 +/// shows something useful instead of raw JSON.
12 +class ChatErrorFormatter {
13 + const ChatErrorFormatter._();
14 +
15 + /// Convenience wrapper used by stream handlers that already have a body.
16 + static String formatBody(String rawBody, {int? statusCode}) {
17 + final decoded = _tryDecodeObject(rawBody);
18 + if (decoded == null) {
19 + final trimmed = rawBody.trim();
20 + if (trimmed.isEmpty) return _genericFromStatus(statusCode).encode();
21 + return ChatApiError(message: trimmed).encode();
22 + }
23 + return _fromKnownShape(decoded, statusCode).encode();
24 + }
25 +
26 + /// Map a `DioException` into the same shape, decoding the response body
27 + /// when it's a stream (which is the case for streaming chat requests).
28 + static Future<String> formatDioException(
29 + dynamic error, {
30 + Future<String> Function(dynamic)? readBody,
31 + }) async {
32 + int? statusCode;
33 + String? rawBody;
34 + final errorType = _readErrorType(error);
35 +
36 + if (errorType != null) {
37 + statusCode = errorType.statusCode;
38 + final data = errorType.response?.data;
39 + if (data != null) {
40 + if (readBody != null) {
41 + try {
42 + rawBody = await readBody(data);
43 + } catch (_) {
44 + rawBody = null;
45 + }
46 + } else if (data is String) {
47 + rawBody = data;
48 + }
49 + }
50 + }
51 +
52 + if (rawBody != null && rawBody.isNotEmpty) {
53 + final body = formatBody(rawBody, statusCode: statusCode);
54 + // If the body produced a generic-shaped message, layer in the status
55 + // code so the bubble shows it.
56 + if (statusCode != null && statusCode >= 400) {
57 + final decoded = _tryDecodeObject(body);
58 + if (decoded != null) {
59 + final merged = ChatApiError.fromErrorMap(decoded);
60 + if (merged != null) {
61 + return merged.copyWithCodeIfMissing(statusCode.toString()).encode();
62 + }
63 + }
64 + }
65 + return body;
66 + }
67 +
68 + if (errorType != null) {
69 + final fromStatus = _genericFromStatus(statusCode);
70 + return fromStatus.copyWithTypeIfMissing(_dioTypeLabel(errorType)).encode();
71 + }
72 +
73 + return ChatApiError(message: error.toString()).encode();
74 + }
75 +
76 + // ---------------------------------------------------------------------------
77 + // Shape detection
78 + // ---------------------------------------------------------------------------
79 +
80 + static ChatApiError _fromKnownShape(
81 + Map<String, dynamic> decoded,
82 + int? statusCode,
83 + ) {
84 + // Ollama / llama.cpp structured error: {"error": {...}}.
85 + final errorField = decoded['error'];
86 + if (errorField is Map) {
87 + final nested = errorField;
88 + final message = nested['message']?.toString();
89 + if (message != null && message.isNotEmpty) {
90 + return ChatApiError(
91 + message: _humanize(message),
92 + type: nested['type']?.toString(),
93 + code: nested['code']?.toString() ?? statusCode?.toString(),
94 + );
95 + }
96 + }
97 + if (errorField is String && errorField.isNotEmpty) {
98 + return ChatApiError(
99 + message: _humanize(errorField),
100 + code: statusCode?.toString(),
101 + );
102 + }
103 +
104 + // OpenAI / OpenRouter / LM Studio: {"error": {"message": ..., "type": ...,
105 + // "code": ..., "param": ...}} or top-level {"message": ...}.
106 + final topMessage = decoded['message'];
107 + if (topMessage is String && topMessage.isNotEmpty) {
108 + return ChatApiError(
109 + message: _humanize(topMessage),
110 + type: decoded['type']?.toString(),
111 + code: decoded['code']?.toString() ?? statusCode?.toString(),
112 + param: decoded['param']?.toString(),
113 + );
114 + }
115 +
116 + // Anthropic-style: {"type": "error", "error": {"type": ..., "message": ...}}
117 + final anthropicType = decoded['type'];
118 + if (anthropicType == 'error' && errorField is Map) {
119 + final nested = errorField;
120 + final message = nested['message']?.toString();
121 + if (message != null && message.isNotEmpty) {
122 + return ChatApiError(
123 + message: _humanize(message),
124 + type: nested['type']?.toString(),
125 + code: statusCode?.toString(),
126 + );
127 + }
128 + }
129 +
130 + return _genericFromStatus(statusCode);
131 + }
132 +
133 + static ChatApiError _genericFromStatus(int? statusCode) {
134 + switch (statusCode) {
135 + case 400:
136 + return const ChatApiError(
137 + message:
138 + 'The server rejected the request. Check the model name, message format, and any attached files.',
139 + );
140 + case 401:
141 + return const ChatApiError(
142 + message: 'Invalid API key or unauthorized.',
143 + type: 'authentication',
144 + );
145 + case 402:
146 + return const ChatApiError(
147 + message: 'Monthly credit limit reached or insufficient balance.',
148 + type: 'billing',
149 + );
150 + case 403:
151 + return const ChatApiError(
152 + message:
153 + 'Access forbidden. Your API key might not have permission for this model.',
154 + type: 'authorization',
155 + );
156 + case 404:
157 + return const ChatApiError(
158 + message:
159 + 'Model not found. Pull or load the model in Ollama, then try again.',
160 + type: 'model_not_found',
161 + );
162 + case 408:
163 + case 504:
164 + return const ChatApiError(
165 + message: 'The model took too long to respond.',
166 + type: 'timeout',
167 + );
168 + case 413:
169 + return const ChatApiError(
170 + message:
171 + 'Image too large for the server to accept. Try enabling image compression in Settings.',
172 + type: 'payload_too_large',
173 + );
174 + case 429:
175 + return const ChatApiError(
176 + message: 'Rate limit exceeded. Please wait before trying again.',
177 + type: 'rate_limited',
178 + );
179 + case 500:
180 + return const ChatApiError(
181 + message: 'Internal server error from the AI provider.',
182 + type: 'server_error',
183 + );
184 + case 502:
185 + return const ChatApiError(
186 + message: 'Bad gateway. The provider is having issues.',
187 + type: 'bad_gateway',
188 + );
189 + case 503:
190 + return const ChatApiError(
191 + message: 'Service unavailable. The provider might be overloaded.',
192 + type: 'service_unavailable',
193 + );
194 + }
195 + return const ChatApiError(message: 'Chat request failed.');
196 + }
197 +
198 + // ---------------------------------------------------------------------------
199 + // Message-level pattern detection
200 + // ---------------------------------------------------------------------------
201 +
202 + static String _humanize(String message) {
203 + final lower = message.toLowerCase();
204 +
205 + if (lower.contains('exceed') &&
206 + (lower.contains('context') || lower.contains('n_ctx'))) {
207 + return 'The conversation is longer than the model\'s context window. '
208 + 'Increase `num_ctx` for the model or start a new chat.';
209 + }
210 + if (lower.contains('model') && lower.contains('not found')) {
211 + return 'The selected model is not loaded on the server. Pull or load it '
212 + 'in Ollama, then try again.';
213 + }
214 + if (lower.contains('unable to load model')) {
215 + return 'Ollama could not load the model. It may need to be re-pulled '
216 + '(e.g. `ollama pull qwen3-vl:2b`).';
217 + }
218 + if (lower.contains('unable to decode image')) {
219 + return 'The server could not decode the attached image. Try a smaller '
220 + 'or different-format image, or enable image compression in Settings.';
221 + }
222 + if (lower.contains('does not support images')) {
223 + return 'The selected model does not support images. Choose a vision '
224 + 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.';
225 + }
226 + if (lower.contains('econnrefused') ||
227 + lower.contains('connection refused') ||
228 + lower.contains('failed to connect')) {
229 + return 'Could not reach the local AI server. Confirm Ollama is running '
230 + '(`ollama serve`) and the address/port in Settings is correct.';
231 + }
232 + if (lower.contains('timeout') || lower.contains('timed out')) {
233 + return 'The model took too long to respond. Try again or increase the '
234 + 'server timeout in Settings.';
235 + }
236 + if (lower.contains('rate limit')) {
237 + return 'Rate limit reached. Wait a moment before retrying.';
238 + }
239 + return message;
240 + }
241 +
242 + // ---------------------------------------------------------------------------
243 + // Helpers
244 + // ---------------------------------------------------------------------------
245 +
246 + static Map<String, dynamic>? _tryDecodeObject(String raw) {
247 + final trimmed = raw.trim();
248 + if (trimmed.isEmpty || !trimmed.startsWith('{')) return null;
249 + try {
250 + final decoded = jsonDecode(trimmed);
251 + if (decoded is Map<String, dynamic>) return decoded;
252 + if (decoded is Map) return Map<String, dynamic>.from(decoded);
253 + } catch (_) {
254 + // Not JSON — leave it for the caller to treat as a plain message.
255 + }
256 + return null;
257 + }
258 +
259 + /// Returns the underlying Dio exception type when available, otherwise
260 + /// null. Defined as a free-standing function instead of importing dio here
261 + /// so the formatter stays free of HTTP-coupling.
262 + static dynamic _readErrorType(dynamic error) {
263 + // Heuristic: anything with a `response` getter and `type` getter is
264 + // likely a DioException. We avoid a hard dependency so callers can pass
265 + // their own typed exception.
266 + try {
267 + final dyn = error as dynamic;
268 + final response = dyn.response;
269 + final type = dyn.type;
270 + if (response != null || type != null) return error;
271 + } catch (_) {
272 + // Not a Dio-shaped object.
273 + }
274 + return null;
275 + }
276 +
277 + static String? _dioTypeLabel(dynamic error) {
278 + try {
279 + final dyn = error as dynamic;
280 + final type = dyn.type?.toString();
281 + return type == null || type.isEmpty ? null : type;
282 + } catch (_) {
283 + return null;
284 + }
285 + }
286 +}
\ No newline at end of file
lib/features/chat/data/chat_service.dart
+124 -48
@@ -19,6 +19,7 @@ import 'tools/adapters/openai_tool_adapter.dart';
19 import 'tools/adapters/openrouter_tool_adapter.dart';
20 import 'tools/adapters/ollama_tool_adapter.dart';
21 import 'chat_api_error.dart';
22 +import 'chat_error_formatter.dart';
23
24 abstract class ChatService {
25 Stream<ChatResponse> sendMessage({
@@ -675,6 +676,36 @@ class OpenAICompatibleChatService implements ChatService {
676 }
677 }
678
679 +String _formatOllamaErrorContent(dynamic error) {
680 + if (error is String) {
681 + return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
682 + }
683 + if (error is Map) {
684 + return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
685 + }
686 + return ChatApiError(message: error.toString()).encode();
687 +}
688 +
689 +Future<String> _handleOllamaChatError(DioException e) async {
690 + return ChatErrorFormatter.formatDioException(
691 + e,
692 + readBody: (data) async {
693 + if (data is ResponseBody) {
694 + return data.stream.cast<List<int>>().transform(utf8.decoder).join();
695 + }
696 + if (data is String) return data;
697 + return '';
698 + },
699 + );
700 +}
701 +
702 +String? _extractOllamaErrorMessage(dynamic data) {
703 + if (data == null) return null;
704 + final raw = data is String ? data : jsonEncode(data);
705 + if (raw.trim().isEmpty) return null;
706 + return ChatErrorFormatter.formatBody(raw);
707 +}
708 +
709 class OllamaChatService implements ChatService {
710 final Dio _dio;
711 final bool imageCompressionEnabled;
@@ -725,12 +756,25 @@ class OllamaChatService implements ChatService {
756 }
757 }
758
759 + final imageCount = apiMessages.fold<int>(0, (count, message) {
760 + final images = message['images'];
761 + return count + (images is List ? images.length : 0);
762 + });
763 + Log.debug(
764 + 'Ollama request: endpoint=${server.chatEndpoint}, model=$modelId, '
765 + 'messages=${apiMessages.length}, images=$imageCount, '
766 + 'tools=${tools?.length ?? 0}',
767 + );
768 +
769 try {
770 final response = await _dio.post<ResponseBody>(
771 server.chatEndpoint,
772 data: body,
773 options: Options(
774 responseType: ResponseType.stream,
775 + // Ollama returns useful JSON error bodies for 4xx responses. Keep
776 + // the stream available so the actual error reaches the UI.
777 + validateStatus: (status) => status != null,
778 headers: {
779 'Content-Type': 'application/json',
780 ...buildServerAuthHeaders(server),
@@ -739,7 +783,26 @@ class OllamaChatService implements ChatService {
783 cancelToken: _cancelToken,
784 );
785
742 - final stream = response.data!.stream.cast<List<int>>().transform(
786 + final responseBody = response.data!;
787 + final statusCode = response.statusCode ?? 0;
788 + if (statusCode < 200 || statusCode >= 300) {
789 + final rawError = await responseBody.stream
790 + .cast<List<int>>()
791 + .transform(utf8.decoder)
792 + .join();
793 + final parsedError = _extractOllamaErrorMessage(rawError);
794 + Log.error('Ollama HTTP $statusCode: ${parsedError ?? rawError}');
795 + yield ChatResponse(
796 + type: ChatResponseType.error,
797 + content: parsedError ??
798 + (rawError.trim().isNotEmpty
799 + ? rawError.trim()
800 + : 'Ollama request failed (HTTP $statusCode).'),
801 + );
802 + return;
803 + }
804 +
805 + final stream = responseBody.stream.cast<List<int>>().transform(
806 utf8.decoder,
807 );
808 String buffer = '';
@@ -754,6 +817,21 @@ class OllamaChatService implements ChatService {
817 try {
818 final json = jsonDecode(line) as Map<String, dynamic>;
819 toolAdapter.consumeDynamicChunk(json);
820 +
821 + // Ollama surfaces mid-stream errors as a top-level `error`
822 + // field, often alongside HTTP 200. Surface them so the UI
823 + // shows something more specific than the generic
824 + // "unknown error" fallback when — for instance — the
825 + // selected model doesn't actually support images.
826 + final errorField = json['error'];
827 + if (errorField != null) {
828 + yield ChatResponse(
829 + type: ChatResponseType.error,
830 + content: _formatOllamaErrorContent(errorField),
831 + );
832 + return;
833 + }
834 +
835 final message = json['message'];
836 if (message != null && message is Map<String, dynamic>) {
837 final content = message['content'] as String?;
@@ -793,10 +871,10 @@ class OllamaChatService implements ChatService {
871 }
872 } catch (e) {
873 Log.error('Ollama connection error: $e');
796 - yield ChatResponse(
797 - type: ChatResponseType.error,
798 - content: _handleChatError(e),
799 - );
874 + final content = e is DioException
875 + ? await _handleOllamaChatError(e)
876 + : _handleChatError(e);
877 + yield ChatResponse(type: ChatResponseType.error, content: content);
878 return;
879 }
880 yield const ChatResponse(type: ChatResponseType.done);
@@ -1128,68 +1206,66 @@ void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1206 }
1207
1208 String _formatApiErrorContent(dynamic error) {
1131 - if (error is Map<String, dynamic>) {
1132 - final parsed = ChatApiError.fromErrorMap(error);
1133 - if (parsed != null) return parsed.encode();
1134 - } else if (error is Map) {
1135 - final parsed = ChatApiError.fromErrorMap(
1136 - Map<String, dynamic>.from(error),
1137 - );
1138 - if (parsed != null) return parsed.encode();
1209 + if (error is Map) {
1210 + final map = Map<String, dynamic>.from(error);
1211 + final parsed = ChatApiError.fromErrorMap(map);
1212 + if (parsed != null) {
1213 + // Use the unified formatter so the bubble benefits from humanising
1214 + // and structured metadata even when the body is already shaped like
1215 + // an OpenAI error.
1216 + try {
1217 + return ChatErrorFormatter.formatBody(jsonEncode(map));
1218 + } catch (_) {
1219 + return parsed.encode();
1220 + }
1221 + }
1222 }
1140 - return error.toString();
1223 + return ChatApiError(message: error.toString()).encode();
1224 }
1225
1226 String _handleChatError(dynamic e) {
1227 if (e is DioException) {
1228 if (e.response != null) {
1146 - final parsed = ChatApiError.fromResponseBody(e.response!.data);
1147 - if (parsed != null) return parsed.encode();
1148 -
1229 final status = e.response!.statusCode;
1150 -
1151 - switch (status) {
1152 - case 401:
1153 - return 'Invalid API key or unauthorized.';
1154 - case 402:
1155 - return 'Monthly credit limit reached or insufficient balance.';
1156 - case 403:
1157 - return 'Access forbidden. Your API key might not have permission for this model.';
1158 - case 404:
1159 - return 'Model not found or API endpoint is incorrect.';
1160 - case 408:
1161 - return 'Request timeout. The server took too long to respond.';
1162 - case 413:
1163 - return 'Image too large for the server to accept. Try enabling '
1164 - 'image compression in Settings.';
1165 - case 429:
1166 - return 'Rate limit exceeded. Please wait a moment before trying again.';
1167 - case 500:
1168 - return 'Internal server error from the AI provider.';
1169 - case 502:
1170 - return 'Bad gateway. The provider is having issues.';
1171 - case 503:
1172 - return 'Service unavailable. The provider might be overloaded.';
1173 - case 504:
1174 - return 'Gateway timeout. The model took too long to respond.';
1230 + final data = e.response!.data;
1231 + if (data is String && data.trim().isNotEmpty) {
1232 + return ChatErrorFormatter.formatBody(data, statusCode: status);
1233 + }
1234 + final parsed = ChatApiError.fromResponseBody(data);
1235 + if (parsed != null) {
1236 + return parsed.copyWithCodeIfMissing(status?.toString()).encode();
1237 }
1176 - return 'Server error ($status): ${e.message}';
1238 + // Let the unified formatter map known HTTP status codes.
1239 + return ChatErrorFormatter.formatBody('', statusCode: status);
1240 }
1241
1242 switch (e.type) {
1243 case DioExceptionType.connectionTimeout:
1244 case DioExceptionType.sendTimeout:
1245 case DioExceptionType.receiveTimeout:
1183 - return 'Connection timed out. Check your internet connection.';
1246 + return const ChatApiError(
1247 + message: 'The model took too long to respond.',
1248 + type: 'timeout',
1249 + ).encode();
1250 case DioExceptionType.connectionError:
1185 - return 'Connection failed. Check if the server is accessible.';
1251 + return const ChatApiError(
1252 + message:
1253 + 'Could not reach the AI server. Confirm it is running and the address in Settings is correct.',
1254 + type: 'connection_error',
1255 + ).encode();
1256 case DioExceptionType.cancel:
1187 - return 'Request cancelled.';
1257 + return const ChatApiError(
1258 + message: 'Request cancelled.',
1259 + type: 'cancelled',
1260 + ).encode();
1261 default:
1189 - return 'Connection error: ${e.message}';
1262 + return ChatApiError(
1263 + message: e.message ?? 'Connection error.',
1264 + type: e.type.name,
1265 + ).encode();
1266 }
1267 }
1192 - return e.toString();
1268 + return ChatApiError(message: e.toString()).encode();
1269 }
1270
1271 String _roleToString(MessageRole role) {
lib/features/chat/providers/chat_notifier.dart
+21
@@ -872,6 +872,7 @@ class ChatNotifier extends Notifier<ChatState> {
872 final collectedToolCalls = <ToolCallData>[];
873
874 bool stateNeedsUpdate = false;
875 + bool streamHadError = false;
876
877 void updateUiState() {
878 if (!stateNeedsUpdate) return;
@@ -961,6 +962,7 @@ class ChatNotifier extends Notifier<ChatState> {
962 break;
963 case ChatResponseType.timeoutError:
964 case ChatResponseType.error:
965 + streamHadError = true;
966 _uiUpdateTimer?.cancel();
967 _uiUpdateTimer = null;
968 streamingAssistantMessage = streamingAssistantMessage.copyWith(
@@ -1007,6 +1009,17 @@ class ChatNotifier extends Notifier<ChatState> {
1009 _uiUpdateTimer = null;
1010 updateUiState();
1011
1012 + if (streamHadError) {
1013 + // The error branch already finalised the message and
1014 + // stopped streaming — do not overwrite the error state
1015 + // with success/default text.
1016 + if (!_isInMemoryChat) {
1017 + await _saveService?.flush();
1018 + }
1019 + ref.read(chatBackgroundServiceProvider).stop();
1020 + return;
1021 + }
1022 +
1023 if (!_isInMemoryChat) {
1024 await _saveService?.flush();
1025 }
@@ -1851,6 +1864,7 @@ class ChatNotifier extends Notifier<ChatState> {
1864 : const <ToolDefinition>[];
1865
1866 bool stateNeedsUpdate = false;
1867 + bool streamHadError = false;
1868
1869 void updateUiState() {
1870 if (!stateNeedsUpdate) return;
@@ -1941,6 +1955,7 @@ class ChatNotifier extends Notifier<ChatState> {
1955 break;
1956 case ChatResponseType.timeoutError:
1957 case ChatResponseType.error:
1958 + streamHadError = true;
1959 _uiUpdateTimer?.cancel();
1960 _uiUpdateTimer = null;
1961 streamingAssistantMessage = _finalizeStreamMessage(
@@ -1977,6 +1992,12 @@ class ChatNotifier extends Notifier<ChatState> {
1992 onDone: () async {
1993 _uiUpdateTimer?.cancel();
1994 _uiUpdateTimer = null;
1995 + if (streamHadError) {
1996 + // The error branch already finalised the message and
1997 + // stopped streaming — do not overwrite the error state
1998 + // with a successful complete message.
1999 + return;
2000 + }
2001 final streamConvId = assistantMessage.conversationId;
2002 final isCurrentContext = _activeConversationId == streamConvId;
2003
lib/features/models/components/model_tile.dart
+11 -6
@@ -62,7 +62,11 @@ class ModelTile extends StatelessWidget {
62 Row(
63 children: [
64 if (isFavorite) ...[
65 - HugeIcon(icon: HugeIcons.strokeRoundedStar, size: 16, color: Colors.amber[600]),
65 + HugeIcon(
66 + icon: HugeIcons.strokeRoundedStar,
67 + size: 16,
68 + color: Colors.amber[600],
69 + ),
70 const SizedBox(width: 6),
71 ],
72 Expanded(
@@ -134,8 +138,8 @@ class ModelTile extends StatelessWidget {
138 model.serverType == ServerType.ollama) &&
139 isLoaded) ...[
140 IconButton(
137 - icon: HugeIcon(icon:
138 - HugeIcons.strokeRoundedPower,
141 + icon: HugeIcon(
142 + icon: HugeIcons.strokeRoundedPower,
143 size: 18,
144 color: Colors.red[400],
145 ),
@@ -158,13 +162,14 @@ class ModelTile extends StatelessWidget {
162 height: 22,
163 child: CircularProgressIndicator(strokeWidth: 2, color: accent),
164 )
161 - else if (isSelected)
165 + else if (isSelected) ...[
166 + SizedBox(width: 4),
167 HugeIcon(
168 icon: HugeIcons.strokeRoundedCheckmarkCircle01,
169 color: accent,
170 size: 22,
166 - )
167 - else
171 + ),
172 + ] else
173 HugeIcon(
174 icon: HugeIcons.strokeRoundedArrowRight01,
175 size: 20,
lib/features/servers/data/models/server.dart
+12
@@ -205,6 +205,18 @@ class Server {
205 }
206 }
207
208 + String get modelDetailsEndpoint {
209 + switch (type) {
210 + case ServerType.ollama:
211 + return '$baseUrl${_apiPath('/api/show')}';
212 + case ServerType.lmStudio:
213 + case ServerType.openAICompatible:
214 + case ServerType.openRouter:
215 + case ServerType.onDevice:
216 + return '';
217 + }
218 + }
219 +
220 String get loadModelEndpoint {
221 switch (type) {
222 case ServerType.lmStudio:
lib/features/servers/data/server_api_service.dart
+59 -4
@@ -46,17 +46,23 @@ class ServerApiService {
46 options: Options(headers: buildServerAuthHeaders(server)),
47 );
48
49 + List<ModelInfo> models;
50 switch (server.type) {
51 case ServerType.lmStudio:
52 case ServerType.openAICompatible:
52 - return _parseOpenAICompatibleModels(response.data, server);
53 + models = _parseOpenAICompatibleModels(response.data, server);
54 + break;
55 case ServerType.ollama:
54 - return _parseOllamaModels(response.data, server);
56 + models = await _parseAndEnrichOllamaModels(response.data, server);
57 + break;
58 case ServerType.openRouter:
56 - return _parseOpenRouterModels(response.data, server);
59 + models = _parseOpenRouterModels(response.data, server);
60 + break;
61 case ServerType.onDevice:
58 - return [];
62 + models = [];
63 + break;
64 }
65 + return models;
66 } catch (e) {
67 throw Exception('Failed to fetch models: $e');
68 }
@@ -409,6 +415,55 @@ class ServerApiService {
415 return null;
416 }
417
418 + Future<List<ModelInfo>> _parseAndEnrichOllamaModels(
419 + dynamic data,
420 + Server server,
421 + ) async {
422 + final models = _parseOllamaModels(data, server);
423 + if (models.isEmpty) return models;
424 +
425 + return Future.wait(
426 + models.map((model) async {
427 + try {
428 + final response = await _dio.post(
429 + server.modelDetailsEndpoint,
430 + data: {'name': model.id},
431 + options: Options(headers: buildServerAuthHeaders(server)),
432 + );
433 + final capabilities = _parseOllamaCapabilityList(response.data);
434 + return model.copyWith(
435 + supportsVision: capabilities.supportsVision,
436 + supportsToolUse: capabilities.supportsToolUse,
437 + );
438 + } catch (error) {
439 + Log.warning(
440 + 'Unable to read Ollama capabilities for ${model.id}: $error',
441 + );
442 + return model;
443 + }
444 + }),
445 + );
446 + }
447 +
448 + ({bool supportsVision, bool supportsToolUse}) _parseOllamaCapabilityList(
449 + dynamic data,
450 + ) {
451 + if (data is! Map) {
452 + return (supportsVision: false, supportsToolUse: false);
453 + }
454 + final rawCapabilities = data['capabilities'];
455 + if (rawCapabilities is! List) {
456 + return (supportsVision: false, supportsToolUse: false);
457 + }
458 + final capabilities = rawCapabilities
459 + .map((value) => value.toString().trim().toLowerCase())
460 + .toSet();
461 + return (
462 + supportsVision: capabilities.contains('vision'),
463 + supportsToolUse: capabilities.contains('tools'),
464 + );
465 + }
466 +
467 List<ModelInfo> _parseOllamaModels(dynamic data, Server server) {
468 final List<ModelInfo> models = [];
469 final modelsData = data['models'];
test/features/chat/data/chat_error_formatter_test.dart new
+55
@@ -0,0 +1,55 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/chat_api_error.dart';
3 +import 'package:localmind/features/chat/data/chat_error_formatter.dart';
4 +
5 +void main() {
6 + group('ChatErrorFormatter', () {
7 + test('decodes Ollama envelope with type tag and humanises context error',
8 + () {
9 + final formatted = ChatErrorFormatter.formatBody(
10 + '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error"}}',
11 + statusCode: 400,
12 + );
13 + final parsed = ChatApiError.tryParse(formatted)!;
14 + expect(parsed.type, 'exceed_context_size_error');
15 + expect(parsed.code, '400');
16 + expect(parsed.message, contains('context window'));
17 + });
18 +
19 + test('decodes Ollama string error and humanises "does not support images"',
20 + () {
21 + final formatted = ChatErrorFormatter.formatBody(
22 + '{"error":"this model does not support images"}',
23 + );
24 + final parsed = ChatApiError.tryParse(formatted)!;
25 + expect(parsed.message, contains('vision model'));
26 + });
27 +
28 + test('decodes OpenAI-style nested error and attaches status code', () {
29 + final formatted = ChatErrorFormatter.formatBody(
30 + '{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}',
31 + statusCode: 401,
32 + );
33 + final parsed = ChatApiError.tryParse(formatted)!;
34 + expect(parsed.message, contains('API key'));
35 + expect(parsed.type, 'invalid_request_error');
36 + // Either the body's code or the status code should appear.
37 + expect(parsed.code, anyOf('invalid_api_key', '401'));
38 + });
39 +
40 + test('falls back to status-only message when body is empty', () {
41 + final formatted = ChatErrorFormatter.formatBody('', statusCode: 429);
42 + final parsed = ChatApiError.tryParse(formatted)!;
43 + expect(parsed.type, 'rate_limited');
44 + expect(parsed.message, contains('Rate limit'));
45 + });
46 +
47 + test('humanises ECONNREFUSED-style messages', () {
48 + final formatted = ChatErrorFormatter.formatBody(
49 + '{"error":"dial tcp 127.0.0.1:11434: connect: connection refused"}',
50 + );
51 + final parsed = ChatApiError.tryParse(formatted)!;
52 + expect(parsed.message.toLowerCase(), contains('ollama'));
53 + });
54 + });
55 +}
\ No newline at end of file
test/features/chat/tools/chat_service_adapter_selection_test.dart
+116 -3
@@ -5,6 +5,7 @@ import 'dart:typed_data';
5 import 'package:dio/dio.dart';
6 import 'package:flutter_test/flutter_test.dart';
7 import 'package:localmind/core/models/enums.dart';
8 +import 'package:localmind/features/chat/data/chat_api_error.dart';
9 import 'package:localmind/features/chat/data/chat_service.dart';
10 import 'package:localmind/features/chat/data/models/message.dart';
11 import 'package:localmind/features/chat/data/models/chat_parameters.dart';
@@ -16,7 +17,8 @@ import 'package:localmind/features/servers/data/models/server.dart';
17
18 class StreamInterceptor extends Interceptor {
19 final List<String> lines;
19 - StreamInterceptor(this.lines);
20 + final int statusCode;
21 + StreamInterceptor(this.lines, {this.statusCode = 200});
22
23 @override
24 void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
@@ -25,12 +27,12 @@ class StreamInterceptor extends Interceptor {
27 requestOptions: options,
28 data: ResponseBody(
29 controller.stream,
28 - 200,
30 + statusCode,
31 headers: {
32 Headers.contentTypeHeader: ['application/json'],
33 },
34 ),
33 - statusCode: 200,
35 + statusCode: statusCode,
36 ));
37
38 // Emit lines asynchronously
@@ -309,6 +311,117 @@ void main() {
311 expect(message.containsKey('images'), isFalse);
312 expect(responses.last.type, ChatResponseType.done);
313 });
314 +
315 + test('surfaces Ollama native stream errors', () async {
316 + final interceptor = CapturingStreamInterceptor([
317 + '{"error":"this model does not support images"}',
318 + ]);
319 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
320 +
321 + final responses = await service
322 + .sendMessage(
323 + server: _ollamaTestServer(),
324 + modelId: 'vision-model',
325 + messages: [
326 + Message(
327 + id: 'failed-vision-message',
328 + conversationId: 'conversation',
329 + role: MessageRole.user,
330 + content: 'What is shown?',
331 + createdAt: DateTime.now(),
332 + ),
333 + ],
334 + params: ChatParameters.defaults(),
335 + )
336 + .toList();
337 +
338 + expect(responses, hasLength(1));
339 + expect(responses.single.type, ChatResponseType.error);
340 + final parsed = ChatApiError.tryParse(responses.single.content);
341 + expect(parsed, isNotNull);
342 + expect(
343 + parsed!.message,
344 + 'The selected model does not support images. Choose a vision '
345 + 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.',
346 + );
347 + });
348 +
349 + test('decodes Ollama HTTP 400 error bodies', () async {
350 + final interceptor = StreamInterceptor(
351 + ['{"error":"unable to decode image"}'],
352 + statusCode: 400,
353 + );
354 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
355 +
356 + final responses = await service
357 + .sendMessage(
358 + server: _ollamaTestServer(),
359 + modelId: 'qwen3-vl:2b',
360 + messages: [
361 + Message(
362 + id: 'http-error-message',
363 + conversationId: 'conversation',
364 + role: MessageRole.user,
365 + content: 'Describe the image',
366 + createdAt: DateTime.now(),
367 + ),
368 + ],
369 + params: ChatParameters.defaults(),
370 + )
371 + .toList();
372 +
373 + expect(responses, hasLength(1));
374 + expect(responses.single.type, ChatResponseType.error);
375 + final parsed = ChatApiError.tryParse(responses.single.content);
376 + expect(parsed, isNotNull);
377 + expect(
378 + parsed!.message,
379 + 'The server could not decode the attached image. Try a smaller '
380 + 'or different-format image, or enable image compression in Settings.',
381 + );
382 + });
383 +
384 + test(
385 + 'decodes structured Ollama HTTP 400 error envelope with type tag',
386 + () async {
387 + final interceptor = StreamInterceptor(
388 + [
389 + '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":4286,"n_ctx":4096}}',
390 + ],
391 + statusCode: 400,
392 + );
393 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
394 +
395 + final responses = await service
396 + .sendMessage(
397 + server: _ollamaTestServer(),
398 + modelId: 'qwen3-vl:2b',
399 + messages: [
400 + Message(
401 + id: 'context-error-message',
402 + conversationId: 'conversation',
403 + role: MessageRole.user,
404 + content: 'Tell me a story',
405 + createdAt: DateTime.now(),
406 + ),
407 + ],
408 + params: ChatParameters.defaults(),
409 + )
410 + .toList();
411 +
412 + expect(responses, hasLength(1));
413 + expect(responses.single.type, ChatResponseType.error);
414 + final parsed = ChatApiError.tryParse(responses.single.content);
415 + expect(parsed, isNotNull);
416 + expect(
417 + parsed!.message,
418 + 'The conversation is longer than the model\'s context window. '
419 + 'Increase `num_ctx` for the model or start a new chat.',
420 + );
421 + expect(parsed.type, 'exceed_context_size_error');
422 + expect(parsed.code, '400');
423 + },
424 + );
425 });
426 }
427
test/features/servers/server_api_service_test.dart
+117
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
3 import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/features/servers/data/models/server.dart';
5 import 'package:localmind/features/servers/data/server_api_service.dart';
6 +import 'dart:convert';
7
8 class TestInterceptor extends Interceptor {
9 final dynamic responseData;
@@ -49,6 +50,68 @@ class FailingInterceptor extends Interceptor {
50 }
51 }
52
53 +class RoutingInterceptor extends Interceptor {
54 + RoutingInterceptor(this.responses);
55 +
56 + /// Maps a request key ("METHOD /path" or "METHOD /path:body-name") to a
57 + /// mock response body. Keys match against the request's path suffix so
58 + /// tests don't need a baseUrl.
59 + final Map<String, dynamic> responses;
60 +
61 + @override
62 + void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
63 + final key = _matchKey(options);
64 + final body = key == null ? null : responses[key];
65 + if (body == null) {
66 + handler.reject(
67 + DioException(
68 + requestOptions: options,
69 + type: DioExceptionType.badResponse,
70 + message: 'No mock for ${options.method} ${options.path}',
71 + ),
72 + );
73 + return;
74 + }
75 + handler.resolve(
76 + Response(requestOptions: options, statusCode: 200, data: body),
77 + );
78 + }
79 +
80 + String? _matchKey(RequestOptions options) {
81 + final method = options.method.toUpperCase();
82 + final path = options.path;
83 + final base = '$method $path';
84 + final suffix = '$method ${_suffix(path)}';
85 + for (final candidate in responses.keys) {
86 + if (candidate == base || candidate == suffix) return candidate;
87 + }
88 + final name = _bodyName(options.data);
89 + if (name == null) return null;
90 + final baseKey = '$base:$name';
91 + if (responses.containsKey(baseKey)) return baseKey;
92 + return '$suffix:$name';
93 + }
94 +
95 + String _suffix(String path) {
96 + final idx = path.indexOf('/api/');
97 + if (idx < 0) return path;
98 + return path.substring(idx);
99 + }
100 +
101 + String? _bodyName(dynamic data) {
102 + if (data is Map) return data['name']?.toString();
103 + if (data is String && data.isNotEmpty) {
104 + try {
105 + final decoded = jsonDecode(data);
106 + if (decoded is Map) return decoded['name']?.toString();
107 + } catch (_) {
108 + // Not JSON — leave name null.
109 + }
110 + }
111 + return null;
112 + }
113 +}
114 +
115 void main() {
116 group('ServerApiService - OpenAI/llama.cpp model list parsing', () {
117 late Server testServer;
@@ -363,6 +426,60 @@ void main() {
426 expect(recorder.requests.first.data, containsPair('prompt', ''));
427 });
428
429 + test(
430 + 'fetchModels enriches Ollama models with vision capability from /api/show',
431 + () async {
432 + final ollamaServer = Server(
433 + id: 'test-ollama',
434 + name: 'Test Ollama',
435 + type: ServerType.ollama,
436 + host: 'localhost',
437 + port: 11434,
438 + createdAt: DateTime.now(),
439 + lastConnectedAt: DateTime.now(),
440 + );
441 +
442 + final interceptor = RoutingInterceptor({
443 + 'GET /api/tags': {
444 + 'models': [
445 + {
446 + 'name': 'qwen3-vl:2b',
447 + 'size': 1800000000,
448 + 'details': {
449 + 'parameter_size': '2.1B',
450 + 'quantization_level': 'Q4_K_M',
451 + 'family': 'qwen3',
452 + },
453 + },
454 + {
455 + 'name': 'llama3.2:3b',
456 + 'size': 2000000000,
457 + 'details': {
458 + 'parameter_size': '3B',
459 + 'quantization_level': 'Q4_K_M',
460 + 'family': 'llama',
461 + },
462 + },
463 + ],
464 + },
465 + 'POST /api/show:qwen3-vl:2b': {
466 + 'capabilities': ['completion', 'vision'],
467 + },
468 + 'POST /api/show:llama3.2:3b': {
469 + 'capabilities': ['completion'],
470 + },
471 + });
472 +
473 + final service = ServerApiService(Dio()..interceptors.add(interceptor));
474 + final models = await service.fetchModels(ollamaServer);
475 +
476 + expect(models, hasLength(2));
477 + final byId = {for (final m in models) m.id: m};
478 + expect(byId['qwen3-vl:2b']?.supportsVision, isTrue);
479 + expect(byId['llama3.2:3b']?.supportsVision, isFalse);
480 + },
481 + );
482 +
483 test('unloadModel for Ollama surfaces failures', () async {
484 final ollamaServer = Server(
485 id: 'test-ollama',