5 files changed
+209
-13
lib/features/chat/data/chat_service.dart
+4
-2
@@ -1418,8 +1418,8 @@ class OpenRouterChatService implements ChatService {
1418
/// Applies the Think toggle to a request body. Different local/hosted
1419
/// backends expose "disable reasoning for this hybrid model" a handful of
1420
/// different ways (a `reasoning` object, a top-level `reasoning_effort`,
1421
-/// llama.cpp's `enable_thinking`) — send all of them so whichever one the
1422
-/// connected server actually understands takes effect.
1421
+/// DeepSeek's `thinking` object, llama.cpp's `enable_thinking`) — send all of
1422
+/// them so whichever one the connected server actually understands takes effect.
1423
/// No-op when [ChatParameters.reasoningEnabled] is null, i.e. the active
1424
/// model doesn't support reasoning.
1425
///
@@ -1432,12 +1432,14 @@ void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1432
'type': 'disabled',
1433
'effort': 'none',
1434
};
1435
+ body['thinking'] = {'type': 'disabled'};
1436
body['reasoning_effort'] = 'none';
1437
body['think'] = false;
1438
body['enable_thinking'] = false;
1439
} else if (params.reasoningEnabled == true) {
1440
final effort = params.reasoningEffort.apiValue;
1441
body['reasoning'] = {'effort': effort};
1442
+ body['thinking'] = {'type': 'enabled'};
1443
body['reasoning_effort'] = effort;
1444
}
1445
}
lib/features/on_device/views/components/imported_model_projector_setting.dart
+3
-6
@@ -1,3 +1,4 @@
1
+import 'package:file_picker/file_picker.dart';
2
import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:hugeicons/hugeicons.dart';
@@ -51,9 +52,7 @@ class _ImportedModelProjectorSettingState
52
);
53
} catch (e) {
54
if (!mounted) return;
54
- messenger?.showSnackBar(
55
- SnackBar(content: Text(e.toString())),
56
- );
55
+ messenger?.showSnackBar(SnackBar(content: Text(e.toString())));
56
} finally {
57
if (mounted) setState(() => _busy = false);
58
}
@@ -75,9 +74,7 @@ class _ImportedModelProjectorSettingState
74
);
75
} catch (e) {
76
if (!mounted) return;
78
- messenger?.showSnackBar(
79
- SnackBar(content: Text(e.toString())),
80
- );
77
+ messenger?.showSnackBar(SnackBar(content: Text(e.toString())));
78
} finally {
79
if (mounted) setState(() => _busy = false);
80
}
lib/features/servers/data/server_api_service.dart
+44
-5
@@ -367,6 +367,30 @@ class ServerApiService {
367
return runningModels;
368
}
369
370
+ bool _isOpenAICompatibleReasoningModel(String modelId) {
371
+ final lower = modelId.trim().toLowerCase();
372
+ if (RegExp(r'(^|[/:\-_])o[1-9]([-_/:.]|$)').hasMatch(lower)) {
373
+ return true;
374
+ }
375
+ if (lower.contains('deepseek-reasoner') ||
376
+ lower.contains('deepseek-r1') ||
377
+ RegExp(r'(^|[-_/])r1([-_/.]|$)').hasMatch(lower)) {
378
+ return true;
379
+ }
380
+ if (lower.contains('qwq') || lower.contains('qvq')) {
381
+ return true;
382
+ }
383
+ if (lower.contains('reasoner') ||
384
+ lower.contains('reasoning') ||
385
+ lower.contains('thinking') ||
386
+ lower.contains('thought') ||
387
+ lower.contains('marco-o1') ||
388
+ lower.contains('skywork-o1')) {
389
+ return true;
390
+ }
391
+ return false;
392
+ }
393
+
394
List<ModelInfo> _parseOpenAICompatibleModels(dynamic data, Server server) {
395
final List<ModelInfo> models = [];
396
if (data == null) return models;
@@ -419,7 +443,22 @@ class ServerApiService {
443
archName = archValue;
444
}
445
422
- final capabilities = _parseModelCapabilities(item['capabilities']);
446
+ final rawCapabilities = item['capabilities'];
447
+ final capabilities = _parseModelCapabilities(rawCapabilities);
448
+ final isReasoning =
449
+ capabilities.supportsReasoning ||
450
+ (rawCapabilities == null &&
451
+ server.type == ServerType.openAICompatible &&
452
+ _isOpenAICompatibleReasoningModel(id));
453
+ final supportedReasoningEfforts = capabilities.supportsReasoning
454
+ ? capabilities.supportedReasoningEfforts
455
+ : (isReasoning ? const ['low', 'medium', 'high'] : null);
456
+ final defaultReasoningEffort = capabilities.supportsReasoning
457
+ ? capabilities.defaultReasoningEffort
458
+ : (isReasoning ? 'medium' : null);
459
+ final reasoningMandatory = capabilities.supportsReasoning
460
+ ? capabilities.reasoningMandatory
461
+ : false;
462
463
models.add(
464
ModelInfo(
@@ -438,11 +477,11 @@ class ServerApiService {
477
serverType: server.type,
478
serverId: server.id,
479
supportsVision: capabilities.supportsVision,
441
- supportsReasoning: capabilities.supportsReasoning,
480
+ supportsReasoning: isReasoning,
481
supportsToolUse: capabilities.supportsToolUse,
443
- supportedReasoningEfforts: capabilities.supportedReasoningEfforts,
444
- defaultReasoningEffort: capabilities.defaultReasoningEffort,
445
- reasoningMandatory: capabilities.reasoningMandatory,
482
+ supportedReasoningEfforts: supportedReasoningEfforts,
483
+ defaultReasoningEffort: defaultReasoningEffort,
484
+ reasoningMandatory: reasoningMandatory,
485
),
486
);
487
}
test/features/chat/tools/chat_service_adapter_selection_test.dart
+106
@@ -744,6 +744,112 @@ void main() {
744
expect(responses.last.type, ChatResponseType.done);
745
},
746
);
747
+
748
+ test(
749
+ 'sends reasoning_effort and thinking when reasoning is enabled',
750
+ () async {
751
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
752
+ final service = OpenAICompatibleChatService(
753
+ Dio()..interceptors.add(interceptor),
754
+ );
755
+
756
+ await service
757
+ .sendMessage(
758
+ server: _openAiTestServer(),
759
+ modelId: 'o3-mini',
760
+ messages: [
761
+ Message(
762
+ id: 'u1',
763
+ conversationId: 'conversation',
764
+ role: MessageRole.user,
765
+ content: 'Hello',
766
+ createdAt: DateTime.now(),
767
+ ),
768
+ ],
769
+ params: ChatParameters.defaults().copyWith(
770
+ reasoningEnabled: true,
771
+ reasoningEffort: ReasoningEffort.low,
772
+ ),
773
+ )
774
+ .toList();
775
+
776
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
777
+ expect(body['reasoning_effort'], 'low');
778
+ expect(body['reasoning'], {'effort': 'low'});
779
+ expect(body['thinking'], {'type': 'enabled'});
780
+ },
781
+ );
782
+
783
+ test(
784
+ 'sends reasoning_effort:none and disabled flags when reasoning is disabled',
785
+ () async {
786
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
787
+ final service = OpenAICompatibleChatService(
788
+ Dio()..interceptors.add(interceptor),
789
+ );
790
+
791
+ await service
792
+ .sendMessage(
793
+ server: _openAiTestServer(),
794
+ modelId: 'deepseek-reasoner',
795
+ messages: [
796
+ Message(
797
+ id: 'u1',
798
+ conversationId: 'conversation',
799
+ role: MessageRole.user,
800
+ content: 'Hello',
801
+ createdAt: DateTime.now(),
802
+ ),
803
+ ],
804
+ params: ChatParameters.defaults().copyWith(
805
+ reasoningEnabled: false,
806
+ ),
807
+ )
808
+ .toList();
809
+
810
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
811
+ expect(body['reasoning_effort'], 'none');
812
+ expect(body['reasoning'], {
813
+ 'enabled': false,
814
+ 'type': 'disabled',
815
+ 'effort': 'none',
816
+ });
817
+ expect(body['thinking'], {'type': 'disabled'});
818
+ expect(body['think'], isFalse);
819
+ expect(body['enable_thinking'], isFalse);
820
+ },
821
+ );
822
+
823
+ test('omits reasoning keys when reasoning is unsupported (null)', () async {
824
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
825
+ final service = OpenAICompatibleChatService(
826
+ Dio()..interceptors.add(interceptor),
827
+ );
828
+
829
+ await service
830
+ .sendMessage(
831
+ server: _openAiTestServer(),
832
+ modelId: 'meta-llama/Llama-3.1-8B-Instruct',
833
+ messages: [
834
+ Message(
835
+ id: 'u1',
836
+ conversationId: 'conversation',
837
+ role: MessageRole.user,
838
+ content: 'Hello',
839
+ createdAt: DateTime.now(),
840
+ ),
841
+ ],
842
+ params: ChatParameters.defaults(),
843
+ )
844
+ .toList();
845
+
846
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
847
+ expect(body.containsKey('reasoning_effort'), isFalse);
848
+ expect(body.containsKey('reasoning'), isFalse);
849
+ expect(body.containsKey('thinking'), isFalse);
850
+ expect(body.containsKey('think'), isFalse);
851
+ expect(body.containsKey('enable_thinking'), isFalse);
852
+ });
853
});
854
855
group('OpenRouter attachment formatting', () {
test/features/servers/server_api_service_test.dart
+52
@@ -251,6 +251,58 @@ void main() {
251
expect(models[2].supportsReasoning, isFalse);
252
});
253
254
+ test(
255
+ 'detects reasoning capability for OpenAI-compatible models without capabilities payload',
256
+ () async {
257
+ final mockData = {
258
+ "object": "list",
259
+ "data": [
260
+ {"id": "o1-mini", "object": "model"},
261
+ {"id": "o3-mini", "object": "model"},
262
+ {"id": "deepseek-reasoner", "object": "model"},
263
+ {
264
+ "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
265
+ "object": "model",
266
+ },
267
+ {"id": "qwq-32b", "object": "model"},
268
+ {"id": "meta-llama/Llama-3.1-8B-Instruct", "object": "model"},
269
+ ],
270
+ };
271
+
272
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
273
+ final service = ServerApiService(dio);
274
+
275
+ final models = await service.fetchModels(testServer);
276
+
277
+ expect(models, hasLength(6));
278
+
279
+ // o1-mini
280
+ expect(models[0].supportsReasoning, isTrue);
281
+ expect(models[0].supportedReasoningEfforts, ['low', 'medium', 'high']);
282
+ expect(models[0].defaultReasoningEffort, 'medium');
283
+ expect(models[0].reasoningMandatory, isFalse);
284
+
285
+ // o3-mini
286
+ expect(models[1].supportsReasoning, isTrue);
287
+ expect(models[1].supportedReasoningEfforts, ['low', 'medium', 'high']);
288
+ expect(models[1].defaultReasoningEffort, 'medium');
289
+
290
+ // deepseek-reasoner
291
+ expect(models[2].supportsReasoning, isTrue);
292
+ expect(models[2].supportedReasoningEfforts, ['low', 'medium', 'high']);
293
+
294
+ // DeepSeek-R1-Distill-Qwen-32B
295
+ expect(models[3].supportsReasoning, isTrue);
296
+
297
+ // qwq-32b
298
+ expect(models[4].supportsReasoning, isTrue);
299
+
300
+ // non-reasoning: Llama-3.1-8B-Instruct
301
+ expect(models[5].supportsReasoning, isFalse);
302
+ expect(models[5].supportedReasoningEfforts, isNull);
303
+ },
304
+ );
305
+
306
test('parses LM Studio allowed_options/default/mandatory', () async {
307
final mockData = {
308
"models": [