@tduncan / Cortana / commits / dfb5b04

fix(chat): scope reasoning controls by provider

Strict OpenAI-compatible servers reject the provider-specific aliases that were sent alongside reasoning_effort. Use only the documented payload for each provider and require explicit capabilities for non-OpenAI model families so optional reasoning does not break otherwise valid requests.

abdulmominsakib committed Sep 21, 2026 at 11:54 UTC dfb5b04730d0277e295141abe873a7d93efaf5a0
4 files changed +111 -65
lib/features/chat/data/chat_service.dart
+24 -25
@@ -674,7 +674,7 @@ class OpenAICompatibleChatService implements ChatService {
674 'max_tokens': params.maxTokens,
675 'stream': true,
676 };
677 - _applyReasoningControl(body, params);
677 + _applyOpenAICompatibleReasoningControl(body, params);
678
679 if (integrations != null && integrations.isNotEmpty) {
680 body['integrations'] = integrations.map((i) => i.toApiJson()).toList();
@@ -1219,7 +1219,7 @@ class OpenRouterChatService implements ChatService {
1219 'max_tokens': params.maxTokens,
1220 'stream': true,
1221 };
1222 - _applyReasoningControl(body, params);
1222 + _applyOpenRouterReasoningControl(body, params);
1223
1224 if (tools != null && tools.isNotEmpty) {
1225 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
@@ -1415,32 +1415,31 @@ class OpenRouterChatService implements ChatService {
1415 }
1416 }
1417
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 -/// 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 -///
1426 -/// Ollama has its own native `think` field and must not receive the generic
1427 -/// keys; use [_applyOllamaReasoningControl] for [OllamaChatService].
1428 -void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1418 +/// Applies the standard OpenAI-compatible `reasoning_effort` field. Avoid
1419 +/// broadcasting provider-specific aliases because strict compatible servers
1420 +/// reject unknown request keys.
1421 +void _applyOpenAICompatibleReasoningControl(
1422 + Map<String, dynamic> body,
1423 + ChatParameters params,
1424 +) {
1425 if (params.reasoningEnabled == false) {
1430 - body['reasoning'] = {
1431 - 'enabled': false,
1432 - 'type': 'disabled',
1433 - 'effort': 'none',
1434 - };
1435 - body['thinking'] = {'type': 'disabled'};
1426 body['reasoning_effort'] = 'none';
1437 - body['think'] = false;
1438 - body['enable_thinking'] = false;
1427 } 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;
1428 + body['reasoning_effort'] = params.reasoningEffort.apiValue;
1429 + }
1430 +}
1431 +
1432 +/// OpenRouter defines a `reasoning` object. Use that object alone instead of
1433 +/// mixing it with the shorthand `reasoning_effort` or unrelated local-server
1434 +/// flags.
1435 +void _applyOpenRouterReasoningControl(
1436 + Map<String, dynamic> body,
1437 + ChatParameters params,
1438 +) {
1439 + if (params.reasoningEnabled == false) {
1440 + body['reasoning'] = {'enabled': false};
1441 + } else if (params.reasoningEnabled == true) {
1442 + body['reasoning'] = {'effort': params.reasoningEffort.apiValue};
1443 }
1444 }
1445
lib/features/servers/data/server_api_service.dart
+1 -17
@@ -372,23 +372,7 @@ class ServerApiService {
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;
375 + return RegExp(r'(^|[/_\-])gpt-[5-9]([._:/\-]|$)').hasMatch(lower);
376 }
377
378 List<ModelInfo> _parseOpenAICompatibleModels(dynamic data, Server server) {
test/features/chat/tools/chat_service_adapter_selection_test.dart
+75 -12
@@ -746,7 +746,7 @@ void main() {
746 );
747
748 test(
749 - 'sends reasoning_effort and thinking when reasoning is enabled',
749 + 'sends only standard reasoning_effort when reasoning is enabled',
750 () async {
751 final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
752 final service = OpenAICompatibleChatService(
@@ -775,13 +775,15 @@ void main() {
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'});
778 + expect(body.containsKey('reasoning'), isFalse);
779 + expect(body.containsKey('thinking'), isFalse);
780 + expect(body.containsKey('think'), isFalse);
781 + expect(body.containsKey('enable_thinking'), isFalse);
782 },
783 );
784
785 test(
784 - 'sends reasoning_effort:none and disabled flags when reasoning is disabled',
786 + 'sends only reasoning_effort:none when reasoning is disabled',
787 () async {
788 final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
789 final service = OpenAICompatibleChatService(
@@ -809,14 +811,10 @@ void main() {
811
812 final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
813 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);
814 + expect(body.containsKey('reasoning'), isFalse);
815 + expect(body.containsKey('thinking'), isFalse);
816 + expect(body.containsKey('think'), isFalse);
817 + expect(body.containsKey('enable_thinking'), isFalse);
818 },
819 );
820
@@ -952,6 +950,71 @@ void main() {
950 'Summarize this\n\n--- notes.txt ---\nattachment text',
951 );
952 });
953 +
954 + test('uses only OpenRouter reasoning configuration when enabled', () async {
955 + final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
956 + final service = OpenRouterChatService(
957 + Dio()..interceptors.add(interceptor),
958 + );
959 +
960 + await service
961 + .sendMessage(
962 + server: _openRouterTestServer(),
963 + modelId: 'openai/o3',
964 + messages: [
965 + Message(
966 + id: 'reasoning-message',
967 + conversationId: 'conversation',
968 + role: MessageRole.user,
969 + content: 'Think carefully',
970 + createdAt: DateTime.now(),
971 + ),
972 + ],
973 + params: ChatParameters.defaults().copyWith(
974 + reasoningEnabled: true,
975 + reasoningEffort: ReasoningEffort.high,
976 + ),
977 + )
978 + .toList();
979 +
980 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
981 + expect(body['reasoning'], {'effort': 'high'});
982 + expect(body.containsKey('reasoning_effort'), isFalse);
983 + expect(body.containsKey('thinking'), isFalse);
984 + expect(body.containsKey('think'), isFalse);
985 + expect(body.containsKey('enable_thinking'), isFalse);
986 + });
987 +
988 + test('uses OpenRouter reasoning disable shape without aliases', () async {
989 + final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
990 + final service = OpenRouterChatService(
991 + Dio()..interceptors.add(interceptor),
992 + );
993 +
994 + await service
995 + .sendMessage(
996 + server: _openRouterTestServer(),
997 + modelId: 'deepseek/deepseek-reasoner',
998 + messages: [
999 + Message(
1000 + id: 'reasoning-message',
1001 + conversationId: 'conversation',
1002 + role: MessageRole.user,
1003 + content: 'Answer directly',
1004 + createdAt: DateTime.now(),
1005 + ),
1006 + ],
1007 + params: ChatParameters.defaults().copyWith(reasoningEnabled: false),
1008 + )
1009 + .toList();
1010 +
1011 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
1012 + expect(body['reasoning'], {'enabled': false});
1013 + expect(body.containsKey('reasoning_effort'), isFalse);
1014 + expect(body.containsKey('thinking'), isFalse);
1015 + expect(body.containsKey('think'), isFalse);
1016 + expect(body.containsKey('enable_thinking'), isFalse);
1017 + });
1018 });
1019
1020 group('OpenAI-compatible & OpenRouter assistant prefill handling', () {
test/features/servers/server_api_service_test.dart
+11 -11
@@ -252,13 +252,14 @@ void main() {
252 });
253
254 test(
255 - 'detects reasoning capability for OpenAI-compatible models without capabilities payload',
255 + 'only infers standard reasoning-effort models without capabilities',
256 () async {
257 final mockData = {
258 "object": "list",
259 "data": [
260 {"id": "o1-mini", "object": "model"},
261 {"id": "o3-mini", "object": "model"},
262 + {"id": "openai/gpt-5-mini", "object": "model"},
263 {"id": "deepseek-reasoner", "object": "model"},
264 {
265 "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
@@ -274,7 +275,7 @@ void main() {
275
276 final models = await service.fetchModels(testServer);
277
277 - expect(models, hasLength(6));
278 + expect(models, hasLength(7));
279
280 // o1-mini
281 expect(models[0].supportsReasoning, isTrue);
@@ -287,19 +288,18 @@ void main() {
288 expect(models[1].supportedReasoningEfforts, ['low', 'medium', 'high']);
289 expect(models[1].defaultReasoningEffort, 'medium');
290
290 - // deepseek-reasoner
291 + // gpt-5-mini
292 expect(models[2].supportsReasoning, isTrue);
293 expect(models[2].supportedReasoningEfforts, ['low', 'medium', 'high']);
294
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
295 + // Provider-specific reasoning models need an explicit capability
296 + // payload so LocalMind does not send unsupported request fields.
297 + expect(models[3].supportsReasoning, isFalse);
298 + expect(models[4].supportsReasoning, isFalse);
299 expect(models[5].supportsReasoning, isFalse);
302 - expect(models[5].supportedReasoningEfforts, isNull);
300 +
301 + expect(models[6].supportsReasoning, isFalse);
302 + expect(models[6].supportedReasoningEfforts, isNull);
303 },
304 );
305