@tduncan / Cortana / commits / bc94dff

feat(openai): add reasoning mode and reasoning_effort support for OpenAI-compatible APIs

abdulmominsakib committed Sep 21, 2026 at 10:11 UTC bc94dff31d34d61b93f9508d2ce4d3e9a4f19ae7
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": [