@tduncan / Cortana / commits / 4ba2c59

feat: improve LM Studio response parsing and error handling in ServerApiService

abdulmominsakib committed Aug 16, 2026 at 19:21 UTC 4ba2c5918106062e93199dabe1923dbcbc3c426d
2 files changed +147 -8
lib/features/servers/data/server_api_service.dart
+59 -8
@@ -195,7 +195,20 @@ class ServerApiService {
195 options: Options(headers: buildServerAuthHeaders(server)),
196 );
197 _throwIfErrorResponse(response.data);
198 - return response.data['instance_id'] as String?;
198 + final data = response.data;
199 + dynamic map = data;
200 + if (data is String && data.trim().startsWith('{')) {
201 + try {
202 + map = jsonDecode(data);
203 + } catch (_) {}
204 + }
205 + if (map is Map) {
206 + return map['instance_id']?.toString() ??
207 + map['id']?.toString() ??
208 + map['model']?.toString() ??
209 + modelId;
210 + }
211 + return modelId;
212 } on DioException catch (e) {
213 throw Exception(_extractApiErrorMessage(e.response?.data) ?? e.message);
214 }
@@ -698,9 +711,32 @@ class ServerApiService {
711 }
712
713 void _throwIfErrorResponse(dynamic data) {
701 - final message = _extractApiErrorMessage(data);
702 - if (message != null) {
703 - throw Exception(message);
714 + if (data == null) return;
715 + dynamic map = data;
716 + if (data is String) {
717 + final trimmed = data.trim();
718 + if (trimmed.startsWith('{')) {
719 + try {
720 + map = jsonDecode(trimmed);
721 + } catch (_) {
722 + return;
723 + }
724 + } else {
725 + return;
726 + }
727 + }
728 + if (map is! Map) return;
729 +
730 + final hasError = map.containsKey('error') && map['error'] != null;
731 + final isFailedStatus = map['status'] == 'error' ||
732 + map['status'] == 'failed' ||
733 + map['success'] == false;
734 +
735 + if (hasError || isFailedStatus) {
736 + final message = _extractApiErrorMessage(map);
737 + if (message != null && message.isNotEmpty) {
738 + throw Exception(message);
739 + }
740 }
741 }
742
@@ -738,16 +774,31 @@ class ServerApiService {
774
775 final message = map['message']?.toString();
776 final type = map['type']?.toString();
741 - final detail = map['detail']?.toString();
777 + final detail = map['detail'];
778 if (message != null && message.trim().isNotEmpty) {
779 if (type != null && type.trim().isNotEmpty) return '$type: $message';
780 return message;
781 }
746 - if (detail != null && detail.trim().isNotEmpty) {
782 +
783 + final errorDescription = map['error_description']?.toString();
784 + if (errorDescription != null && errorDescription.trim().isNotEmpty) {
785 + return errorDescription;
786 + }
787 +
788 + if (detail is String && detail.trim().isNotEmpty) {
789 + if (type != null && type.trim().isNotEmpty) return '$type: $detail';
790 return detail;
791 + } else if (detail is List && detail.isNotEmpty) {
792 + final msgs = detail
793 + .map((d) => d is Map ? d['msg']?.toString() : d?.toString())
794 + .where((m) => m != null && m.trim().isNotEmpty)
795 + .join(', ');
796 + if (msgs.isNotEmpty) return msgs;
797 }
749 - if (type != null && type.trim().isNotEmpty) {
750 - return type;
798 +
799 + final title = map['title']?.toString();
800 + if (title != null && title.trim().isNotEmpty) {
801 + return title;
802 }
803
804 return null;
test/features/servers/server_api_service_test.dart
+88
@@ -679,4 +679,92 @@ void main() {
679 expect(models.single.pricingLabel, isNull);
680 });
681 });
682 +
683 + group('ServerApiService - LM Studio load model behavior', () {
684 + late Server lmStudioServer;
685 +
686 + setUp(() {
687 + lmStudioServer = Server(
688 + id: 'test-lmstudio',
689 + name: 'Test LM Studio',
690 + type: ServerType.lmStudio,
691 + host: 'localhost',
692 + port: 1234,
693 + createdAt: DateTime.now(),
694 + lastConnectedAt: DateTime.now(),
695 + );
696 + });
697 +
698 + test('loadModelWithInstanceId succeeds with LM Studio response having "type": "llm"', () async {
699 + final successData = {
700 + 'type': 'llm',
701 + 'instance_id': 'liquid/lfm2-1.2b',
702 + 'load_config': {
703 + 'context_length': 4096,
704 + },
705 + };
706 +
707 + final dio = Dio()..interceptors.add(TestInterceptor(successData));
708 + final service = ServerApiService(dio);
709 +
710 + final instanceId = await service.loadModelWithInstanceId(
711 + lmStudioServer,
712 + 'liquid/lfm2-1.2b',
713 + contextLength: 4096,
714 + );
715 +
716 + expect(instanceId, 'liquid/lfm2-1.2b');
717 + });
718 +
719 + test('loadModelWithInstanceId throws when response contains error object', () async {
720 + final errorData = {
721 + 'error': {
722 + 'type': 'invalid_request_error',
723 + 'message': 'Context length exceeds model limit',
724 + },
725 + };
726 +
727 + final dio = Dio()..interceptors.add(TestInterceptor(errorData));
728 + final service = ServerApiService(dio);
729 +
730 + expect(
731 + () => service.loadModelWithInstanceId(
732 + lmStudioServer,
733 + 'liquid/lfm2-1.2b',
734 + contextLength: 999999,
735 + ),
736 + throwsA(
737 + predicate(
738 + (e) =>
739 + e is Exception &&
740 + e.toString().contains('invalid_request_error: Context length exceeds model limit'),
741 + ),
742 + ),
743 + );
744 + });
745 +
746 + test('loadModelWithInstanceId throws when response contains status error', () async {
747 + final errorData = {
748 + 'status': 'error',
749 + 'message': 'Failed to allocate memory',
750 + };
751 +
752 + final dio = Dio()..interceptors.add(TestInterceptor(errorData));
753 + final service = ServerApiService(dio);
754 +
755 + expect(
756 + () => service.loadModelWithInstanceId(
757 + lmStudioServer,
758 + 'liquid/lfm2-1.2b',
759 + ),
760 + throwsA(
761 + predicate(
762 + (e) =>
763 + e is Exception &&
764 + e.toString().contains('Failed to allocate memory'),
765 + ),
766 + ),
767 + );
768 + });
769 + });
770 }