@tduncan / Cortana / commits / 7f3da65

feat: extend Ollama model capabilities with reasoning support and configurable efforts

abdulmominsakib committed Sep 8, 2026 at 10:48 UTC 7f3da65aec06ad1040b846f46af7e4ba4b5319d7
8 files changed +717 -32
lib/features/chat/data/chat_service.dart
+38 -10
@@ -920,6 +920,11 @@ class OllamaChatService implements ChatService {
920 for (final message in messages) {
921 apiMessages.add(await _messageToOllamaMapWithImages(message));
922 }
923 + // Strip the trailing empty assistant placeholder created for streaming.
924 + // Ollama's Qwen 3.5 parser treats any trailing assistant turn as an open
925 + // prefill; an empty one misclassifies the generated continuation as
926 + // `message.thinking` (see ollama/ollama#17187 and localmind#78).
927 + _normalizeAssistantPrefill(apiMessages, continueGeneration);
928
929 final body = <String, dynamic>{
930 'model': modelId,
@@ -931,7 +936,7 @@ class OllamaChatService implements ChatService {
936 'num_predict': params.maxTokens,
937 },
938 };
934 - _applyReasoningControl(body, params);
939 + _applyOllamaReasoningControl(body, params);
940
941 if (tools != null && tools.isNotEmpty) {
942 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
@@ -1383,10 +1388,13 @@ class OpenRouterChatService implements ChatService {
1388 /// Applies the Think toggle to a request body. Different local/hosted
1389 /// backends expose "disable reasoning for this hybrid model" a handful of
1390 /// different ways (a `reasoning` object, a top-level `reasoning_effort`,
1386 -/// llama.cpp's `enable_thinking`, Ollama's `think`) — send all of them so
1387 -/// whichever one the connected server actually understands takes effect.
1391 +/// llama.cpp's `enable_thinking`) — send all of them so whichever one the
1392 +/// connected server actually understands takes effect.
1393 /// No-op when [ChatParameters.reasoningEnabled] is null, i.e. the active
1394 /// model doesn't support reasoning.
1395 +///
1396 +/// Ollama has its own native `think` field and must not receive the generic
1397 +/// keys; use [_applyOllamaReasoningControl] for [OllamaChatService].
1398 void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1399 if (params.reasoningEnabled == false) {
1400 body['reasoning'] = {
@@ -1404,6 +1412,24 @@ void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1412 }
1413 }
1414
1415 +/// Applies the Think toggle using only Ollama's native `/api/chat` `think`
1416 +/// field (`true`/`false` or `"low"`/`"medium"`/`"high"`/`"max"`). Omits the
1417 +/// key when the model doesn't support reasoning or can't run with thinking
1418 +/// off, so strict servers and proxies never see unrelated `reasoning`,
1419 +/// `reasoning_effort`, or `enable_thinking` keys.
1420 +void _applyOllamaReasoningControl(
1421 + Map<String, dynamic> body,
1422 + ChatParameters params,
1423 +) {
1424 + final think = resolveOllamaThinkValue(
1425 + enabled: params.reasoningEnabled,
1426 + effort: params.reasoningEffort,
1427 + allowedOptions: params.reasoningAllowedOptions,
1428 + defaultOption: params.reasoningDefaultOption,
1429 + );
1430 + if (think != null) body['think'] = think;
1431 +}
1432 +
1433 String _formatApiErrorContent(dynamic error) {
1434 if (error is Map) {
1435 final map = Map<String, dynamic>.from(error);
@@ -1480,13 +1506,15 @@ String _roleToString(MessageRole role) {
1506 }
1507 }
1508
1483 -/// Normalises the trailing assistant message in an OpenAI-style `messages`
1484 -/// array. When NOT continuing generation, drops trailing empty assistant
1485 -/// prefill messages (they trigger "prefill incompatible with
1486 -/// enable_thinking" errors on thinking models). When continuing generation,
1487 -/// keeps the trailing assistant prefill but ensures its `content` is a
1488 -/// plain string (image content-part arrays are only valid on user messages,
1489 -/// and [_buildOpenAiApiMessage] never produces them for assistants anyway).
1509 +/// Normalises the trailing assistant message in a chat `messages` array.
1510 +/// When NOT continuing generation, drops trailing empty assistant prefill
1511 +/// messages. Besides triggering "prefill incompatible with enable_thinking"
1512 +/// errors on OpenAI-style thinking models, an empty trailing assistant turn
1513 +/// makes Ollama's Qwen 3.5 parser misclassify the generated continuation as
1514 +/// `message.thinking`. When continuing generation, keeps the trailing
1515 +/// assistant prefill but ensures its `content` is a plain string (image
1516 +/// content-part arrays are only valid on user messages, and
1517 +/// [_buildOpenAiApiMessage] never produces them for assistants anyway).
1518 void _normalizeAssistantPrefill(
1519 List<Map<String, dynamic>> apiMessages,
1520 bool continueGeneration,
lib/features/chat/data/models/chat_parameters.dart
+8 -7
@@ -16,15 +16,16 @@ class ChatParameters {
16 final bool? reasoningEnabled;
17 final ReasoningEffort reasoningEffort;
18
19 - /// Raw LM Studio `allowed_options` for the active model (e.g.
20 - /// `["low","medium","high","xhigh"]`, `["off","on"]`). Null when unknown
21 - /// (legacy servers) or for non-LM-Studio providers. Lets
22 - /// [LMStudioChatService] send exactly what the server advertises and omit
23 - /// the key when `off` isn't allowed instead of triggering HTTP 400.
19 + /// Raw reasoning `allowed_options` for the active model (e.g. LM Studio
20 + /// `["low","medium","high","xhigh"]`, `["off","on"]`; Ollama binary
21 + /// `["off","on"]` or GPT-OSS `["low","medium","high"]`). Null when unknown
22 + /// (legacy servers) or for providers without per-model options. Lets
23 + /// [LMStudioChatService] and Ollama send exactly what the server advertises
24 + /// and omit the key when `off` isn't allowed instead of triggering HTTP 400.
25 final List<String>? reasoningAllowedOptions;
26
26 - /// Raw LM Studio `reasoning.default` for the active model. Used as a
27 - /// tie-breaker when snapping efforts.
27 + /// Raw reasoning default for the active model. Used as a tie-breaker when
28 + /// snapping efforts.
29 final String? reasoningDefaultOption;
30
31 const ChatParameters({
lib/features/chat/providers/chat_reasoning_providers.dart
+59
@@ -107,6 +107,65 @@ ReasoningEffort resolveEffortForModel(
107 return ReasoningEffort.fromApiValue(supported.first);
108 }
109
110 +/// Resolves the exact `think` value for Ollama's native `/api/chat`, or null
111 +/// when the key should be omitted (model doesn't support reasoning or the
112 +/// model can't run with thinking off).
113 +///
114 +/// - [enabled] null (model doesn't support reasoning) → null (omit).
115 +/// - Disabled + `off` advertised (or unknown caps) → `false`.
116 +/// - Disabled + `off` NOT advertised (e.g. GPT-OSS `["low","medium","high"]`)
117 +/// → null (omit). Sending `false` there is ignored, so omitting lets the
118 +/// server use its default instead of implying an unsupported mode.
119 +/// - Enabled → `true` for binary `["off","on"]` models; the granular level
120 +/// (`"low"`, `"medium"`, `"high"`, `"max"`) when advertised;
121 +/// `minimal`→`"low"` and `xhigh`→`"high"` normalization for Ollama, which
122 +/// never advertises those two.
123 +Object? resolveOllamaThinkValue({
124 + required bool? enabled,
125 + required ReasoningEffort effort,
126 + List<String>? allowedOptions,
127 + String? defaultOption,
128 +}) {
129 + if (enabled == null) return null;
130 + final allowed = allowedOptions
131 + ?.map((e) => e.trim().toLowerCase())
132 + .where((e) => e.isNotEmpty)
133 + .toSet();
134 + final def = defaultOption?.trim().toLowerCase();
135 +
136 + if (!enabled) {
137 + if (allowed == null || allowed.isEmpty) return false;
138 + if (allowed.contains('off')) return false;
139 + return null;
140 + }
141 +
142 + if (allowed == null || allowed.isEmpty) return true;
143 +
144 + const ollamaLevels = ['low', 'medium', 'high', 'max'];
145 + final granularAllowed = ollamaLevels
146 + .where((level) => allowed.contains(level))
147 + .toList(growable: false);
148 +
149 + if (granularAllowed.isEmpty) {
150 + if (allowed.contains('on')) return true;
151 + if (def != null && allowed.contains(def)) {
152 + if (def == 'on') return true;
153 + if (ollamaLevels.contains(def)) return def;
154 + }
155 + return true;
156 + }
157 +
158 + var candidate = effort.apiValue;
159 + if (effort == ReasoningEffort.minimal) candidate = 'low';
160 + if (effort == ReasoningEffort.xhigh) candidate = 'high';
161 +
162 + if (allowed.contains(candidate)) return candidate;
163 +
164 + if (def != null && granularAllowed.contains(def)) return def;
165 + if (candidate == 'low') return granularAllowed.first;
166 + return granularAllowed.last;
167 +}
168 +
169 /// Resolves the exact `reasoning` string for LM Studio's native
170 /// `/api/v1/chat`, or null when the key should be omitted (server default).
171 ///
lib/features/models/data/models/model_info.dart
+6 -3
@@ -22,8 +22,10 @@ class ModelInfo {
22 final bool supportsToolUse;
23
24 /// Effort strings the model advertises for reasoning (OpenRouter
25 - /// `reasoning.supported_efforts`), e.g. `["minimal","low","medium","high"]`.
26 - /// Null for providers that don't expose per-model efforts.
25 + /// `reasoning.supported_efforts`), e.g. `["minimal","low","medium","high"]`;
26 + /// LM Studio `allowed_options`; Ollama binary `["off","on"]` or GPT-OSS
27 + /// `["low","medium","high"]`. Null for providers that don't expose
28 + /// per-model efforts.
29 final List<String>? supportedReasoningEfforts;
30
31 /// The model's default reasoning effort (OpenRouter
@@ -31,7 +33,8 @@ class ModelInfo {
33 final String? defaultReasoningEffort;
34
35 /// When true the model cannot run without reasoning (OpenRouter
34 - /// `reasoning.mandatory`); the "Off" toggle should be hidden.
36 + /// `reasoning.mandatory`, Ollama GPT-OSS); the "Off" toggle should be
37 + /// hidden.
38 final bool reasoningMandatory;
39
40 /// Input token price in USD per 1M tokens (OpenRouter `pricing.prompt`).
lib/features/servers/data/server_api_service.dart
+53 -10
@@ -567,13 +567,17 @@ class ServerApiService {
567 try {
568 final response = await _dio.post(
569 server.modelDetailsEndpoint,
570 - data: {'name': model.id},
570 + data: {'model': model.id},
571 options: Options(headers: buildServerAuthHeaders(server)),
572 );
573 final capabilities = _parseOllamaCapabilityList(response.data);
574 return model.copyWith(
575 supportsVision: capabilities.supportsVision,
576 + supportsReasoning: capabilities.supportsReasoning,
577 supportsToolUse: capabilities.supportsToolUse,
578 + supportedReasoningEfforts: capabilities.supportedReasoningEfforts,
579 + defaultReasoningEffort: capabilities.defaultReasoningEffort,
580 + reasoningMandatory: capabilities.reasoningMandatory,
581 );
582 } catch (error) {
583 Log.warning(
@@ -585,22 +589,61 @@ class ServerApiService {
589 );
590 }
591
588 - ({bool supportsVision, bool supportsToolUse}) _parseOllamaCapabilityList(
589 - dynamic data,
590 - ) {
592 + ({
593 + bool supportsVision,
594 + bool supportsReasoning,
595 + bool supportsToolUse,
596 + List<String>? supportedReasoningEfforts,
597 + String? defaultReasoningEffort,
598 + bool reasoningMandatory,
599 + })
600 + _parseOllamaCapabilityList(dynamic data) {
601 if (data is! Map) {
592 - return (supportsVision: false, supportsToolUse: false);
602 + return (
603 + supportsVision: false,
604 + supportsReasoning: false,
605 + supportsToolUse: false,
606 + supportedReasoningEfforts: null,
607 + defaultReasoningEffort: null,
608 + reasoningMandatory: false,
609 + );
610 }
611 final rawCapabilities = data['capabilities'];
595 - if (rawCapabilities is! List) {
596 - return (supportsVision: false, supportsToolUse: false);
612 + final capabilities = rawCapabilities is List
613 + ? rawCapabilities
614 + .map((value) => value.toString().trim().toLowerCase())
615 + .toSet()
616 + : <String>{};
617 +
618 + final details = data['details'];
619 + final familyNames = <String>{};
620 + if (details is Map) {
621 + final family = details['family']?.toString().trim().toLowerCase();
622 + if (family != null && family.isNotEmpty) familyNames.add(family);
623 + final families = details['families'];
624 + if (families is List) {
625 + familyNames.addAll(
626 + families
627 + .map((value) => value.toString().trim().toLowerCase())
628 + .where((value) => value.isNotEmpty),
629 + );
630 + }
631 }
598 - final capabilities = rawCapabilities
599 - .map((value) => value.toString().trim().toLowerCase())
600 - .toSet();
632 + final isGptOss = familyNames.any(
633 + (family) => family == 'gptoss' || family == 'gpt-oss',
634 + );
635 + final supportsReasoning = capabilities.contains('thinking') || isGptOss;
636 + final reasoningMandatory = isGptOss;
637 +
638 return (
639 supportsVision: capabilities.contains('vision'),
640 + supportsReasoning: supportsReasoning,
641 supportsToolUse: capabilities.contains('tools'),
642 + supportedReasoningEfforts: supportsReasoning
643 + ? (isGptOss ? const ['low', 'medium', 'high'] : const ['off', 'on'])
644 + : null,
645 + defaultReasoningEffort: null,
646 + reasoningMandatory: reasoningMandatory,
647 );
648 }
649
test/features/chat/providers/chat_reasoning_providers_test.dart
+96
@@ -230,6 +230,102 @@ void main() {
230 });
231 });
232
233 + group('resolveOllamaThinkValue', () {
234 + test('omits when reasoning is unsupported', () {
235 + expect(
236 + resolveOllamaThinkValue(enabled: null, effort: ReasoningEffort.low),
237 + isNull,
238 + );
239 + });
240 +
241 + test('legacy null caps send boolean true/false', () {
242 + expect(
243 + resolveOllamaThinkValue(enabled: true, effort: ReasoningEffort.low),
244 + isTrue,
245 + );
246 + expect(
247 + resolveOllamaThinkValue(enabled: false, effort: ReasoningEffort.low),
248 + isFalse,
249 + );
250 + });
251 +
252 + test('binary off/on models send booleans', () {
253 + expect(
254 + resolveOllamaThinkValue(
255 + enabled: true,
256 + effort: ReasoningEffort.low,
257 + allowedOptions: const ['off', 'on'],
258 + ),
259 + isTrue,
260 + );
261 + expect(
262 + resolveOllamaThinkValue(
263 + enabled: false,
264 + effort: ReasoningEffort.low,
265 + allowedOptions: const ['off', 'on'],
266 + ),
267 + isFalse,
268 + );
269 + });
270 +
271 + test('omits off when the model cannot disable thinking', () {
272 + expect(
273 + resolveOllamaThinkValue(
274 + enabled: false,
275 + effort: ReasoningEffort.low,
276 + allowedOptions: const ['low', 'medium', 'high'],
277 + ),
278 + isNull,
279 + );
280 + });
281 +
282 + test('granular models send the selected level', () {
283 + expect(
284 + resolveOllamaThinkValue(
285 + enabled: true,
286 + effort: ReasoningEffort.medium,
287 + allowedOptions: const ['low', 'medium', 'high'],
288 + ),
289 + 'medium',
290 + );
291 + expect(
292 + resolveOllamaThinkValue(
293 + enabled: true,
294 + effort: ReasoningEffort.low,
295 + allowedOptions: const ['low', 'medium', 'high'],
296 + ),
297 + 'low',
298 + );
299 + });
300 +
301 + test('snaps efforts Ollama does not advertise', () {
302 + expect(
303 + resolveOllamaThinkValue(
304 + enabled: true,
305 + effort: ReasoningEffort.minimal,
306 + allowedOptions: const ['low', 'medium', 'high'],
307 + ),
308 + 'low',
309 + );
310 + expect(
311 + resolveOllamaThinkValue(
312 + enabled: true,
313 + effort: ReasoningEffort.xhigh,
314 + allowedOptions: const ['low', 'medium', 'high'],
315 + ),
316 + 'high',
317 + );
318 + expect(
319 + resolveOllamaThinkValue(
320 + enabled: true,
321 + effort: ReasoningEffort.max,
322 + allowedOptions: const ['low', 'medium', 'high'],
323 + ),
324 + 'high',
325 + );
326 + });
327 + });
328 +
329 group('ReasoningEffort.fromApiValue', () {
330 test('maps known values', () {
331 expect(ReasoningEffort.fromApiValue('minimal'), ReasoningEffort.minimal);
test/features/chat/tools/chat_service_adapter_selection_test.dart
+231
@@ -9,6 +9,7 @@ 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';
12 +import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
13 import 'package:localmind/features/chat/data/tools/tool_definition.dart';
14 import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
15 import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
@@ -831,6 +832,236 @@ void main() {
832 expect(msgs.last['role'], 'user');
833 });
834 });
835 +
836 + group('Ollama thinking control and prefill handling', () {
837 + test(
838 + 'strips trailing empty assistant placeholder on normal generation',
839 + () async {
840 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
841 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
842 +
843 + await service
844 + .sendMessage(
845 + server: _ollamaTestServer(),
846 + modelId: 'qwen3.5:9b',
847 + messages: [
848 + Message(
849 + id: 'u1',
850 + conversationId: 'c',
851 + role: MessageRole.user,
852 + content: 'hi',
853 + createdAt: DateTime.now(),
854 + ),
855 + Message(
856 + id: 'a1',
857 + conversationId: 'c',
858 + role: MessageRole.assistant,
859 + content: '',
860 + createdAt: DateTime.now(),
861 + ),
862 + ],
863 + params: ChatParameters.defaults(),
864 + )
865 + .toList();
866 +
867 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
868 + final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
869 + expect(msgs, hasLength(1));
870 + expect(msgs.single['role'], 'user');
871 + },
872 + );
873 +
874 + test('keeps non-empty assistant prefill when continuing', () async {
875 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
876 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
877 +
878 + await service
879 + .sendMessage(
880 + server: _ollamaTestServer(),
881 + modelId: 'qwen3.5:9b',
882 + messages: [
883 + Message(
884 + id: 'u1',
885 + conversationId: 'c',
886 + role: MessageRole.user,
887 + content: 'hi',
888 + createdAt: DateTime.now(),
889 + ),
890 + Message(
891 + id: 'a1',
892 + conversationId: 'c',
893 + role: MessageRole.assistant,
894 + content: 'Sure, ',
895 + createdAt: DateTime.now(),
896 + ),
897 + ],
898 + params: ChatParameters.defaults(),
899 + continueGeneration: true,
900 + )
901 + .toList();
902 +
903 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
904 + final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
905 + expect(msgs, hasLength(2));
906 + expect(msgs.last['role'], 'assistant');
907 + expect(msgs.last['content'], 'Sure, ');
908 + });
909 +
910 + test('sends think:false for binary model when disabled', () async {
911 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
912 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
913 +
914 + await service
915 + .sendMessage(
916 + server: _ollamaTestServer(),
917 + modelId: 'qwen3.5:9b',
918 + messages: [
919 + Message(
920 + id: 'u1',
921 + conversationId: 'c',
922 + role: MessageRole.user,
923 + content: 'hi',
924 + createdAt: DateTime.now(),
925 + ),
926 + ],
927 + params: ChatParameters.defaults().copyWith(
928 + reasoningEnabled: false,
929 + reasoningAllowedOptions: const ['off', 'on'],
930 + ),
931 + )
932 + .toList();
933 +
934 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
935 + expect(body['think'], isFalse);
936 + expect(body.containsKey('reasoning'), isFalse);
937 + expect(body.containsKey('reasoning_effort'), isFalse);
938 + expect(body.containsKey('enable_thinking'), isFalse);
939 + });
940 +
941 + test('sends think:true for binary model when enabled', () async {
942 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
943 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
944 +
945 + await service
946 + .sendMessage(
947 + server: _ollamaTestServer(),
948 + modelId: 'qwen3.5:9b',
949 + messages: [
950 + Message(
951 + id: 'u1',
952 + conversationId: 'c',
953 + role: MessageRole.user,
954 + content: 'hi',
955 + createdAt: DateTime.now(),
956 + ),
957 + ],
958 + params: ChatParameters.defaults().copyWith(
959 + reasoningEnabled: true,
960 + reasoningEffort: ReasoningEffort.low,
961 + reasoningAllowedOptions: const ['off', 'on'],
962 + ),
963 + )
964 + .toList();
965 +
966 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
967 + expect(body['think'], isTrue);
968 + expect(body.containsKey('reasoning'), isFalse);
969 + expect(body.containsKey('reasoning_effort'), isFalse);
970 + expect(body.containsKey('enable_thinking'), isFalse);
971 + });
972 +
973 + test('omits think when reasoning is unsupported', () async {
974 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
975 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
976 +
977 + await service
978 + .sendMessage(
979 + server: _ollamaTestServer(),
980 + modelId: 'llama3.2:3b',
981 + messages: [
982 + Message(
983 + id: 'u1',
984 + conversationId: 'c',
985 + role: MessageRole.user,
986 + content: 'hi',
987 + createdAt: DateTime.now(),
988 + ),
989 + ],
990 + params: ChatParameters.defaults(),
991 + )
992 + .toList();
993 +
994 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
995 + expect(body.containsKey('think'), isFalse);
996 + expect(body.containsKey('reasoning'), isFalse);
997 + expect(body.containsKey('reasoning_effort'), isFalse);
998 + expect(body.containsKey('enable_thinking'), isFalse);
999 + });
1000 +
1001 + test('sends granular level for GPT-OSS when enabled', () async {
1002 + final interceptor = CapturingStreamInterceptor(['{"done":true}']);
1003 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
1004 +
1005 + await service
1006 + .sendMessage(
1007 + server: _ollamaTestServer(),
1008 + modelId: 'gpt-oss:20b',
1009 + messages: [
1010 + Message(
1011 + id: 'u1',
1012 + conversationId: 'c',
1013 + role: MessageRole.user,
1014 + content: 'hi',
1015 + createdAt: DateTime.now(),
1016 + ),
1017 + ],
1018 + params: ChatParameters.defaults().copyWith(
1019 + reasoningEnabled: true,
1020 + reasoningEffort: ReasoningEffort.medium,
1021 + reasoningAllowedOptions: const ['low', 'medium', 'high'],
1022 + ),
1023 + )
1024 + .toList();
1025 +
1026 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
1027 + expect(body['think'], 'medium');
1028 + expect(body.containsKey('reasoning'), isFalse);
1029 + expect(body.containsKey('reasoning_effort'), isFalse);
1030 + expect(body.containsKey('enable_thinking'), isFalse);
1031 + });
1032 +
1033 + test('streams separate reasoning and content from one chunk', () async {
1034 + final interceptor = CapturingStreamInterceptor([
1035 + '{"message":{"thinking":"Let me think","content":"Final answer"},"done":false}',
1036 + '{"done":true}',
1037 + ]);
1038 + final service = OllamaChatService(Dio()..interceptors.add(interceptor));
1039 +
1040 + final responses = await service
1041 + .sendMessage(
1042 + server: _ollamaTestServer(),
1043 + modelId: 'qwen3.5:9b',
1044 + messages: [
1045 + Message(
1046 + id: 'u1',
1047 + conversationId: 'c',
1048 + role: MessageRole.user,
1049 + content: 'hi',
1050 + createdAt: DateTime.now(),
1051 + ),
1052 + ],
1053 + params: ChatParameters.defaults(),
1054 + )
1055 + .toList();
1056 +
1057 + expect(responses, hasLength(3));
1058 + expect(responses[0].type, ChatResponseType.reasoning);
1059 + expect(responses[0].reasoningContent, 'Let me think');
1060 + expect(responses[1].type, ChatResponseType.message);
1061 + expect(responses[1].content, 'Final answer');
1062 + expect(responses[2].type, ChatResponseType.done);
1063 + });
1064 + });
1065 }
1066
1067 Server _openAiTestServer() {
test/features/servers/server_api_service_test.dart
+226 -2
@@ -97,11 +97,15 @@ class RoutingInterceptor extends Interceptor {
97 }
98
99 String? _bodyName(dynamic data) {
100 - if (data is Map) return data['name']?.toString();
100 + if (data is Map) {
101 + return data['model']?.toString() ?? data['name']?.toString();
102 + }
103 if (data is String && data.isNotEmpty) {
104 try {
105 final decoded = jsonDecode(data);
104 - if (decoded is Map) return decoded['name']?.toString();
106 + if (decoded is Map) {
107 + return decoded['model']?.toString() ?? decoded['name']?.toString();
108 + }
109 } catch (_) {
110 // Not JSON — leave name null.
111 }
@@ -562,6 +566,226 @@ void main() {
566 },
567 );
568
569 + test('fetchModels sends canonical model key to Ollama /api/show', () async {
570 + final ollamaServer = Server(
571 + id: 'test-ollama',
572 + name: 'Test Ollama',
573 + type: ServerType.ollama,
574 + host: 'localhost',
575 + port: 11434,
576 + createdAt: DateTime.now(),
577 + lastConnectedAt: DateTime.now(),
578 + );
579 +
580 + final showBodies = <dynamic>[];
581 + final dio = Dio()
582 + ..interceptors.add(
583 + InterceptorsWrapper(
584 + onRequest: (options, handler) {
585 + if (options.path.endsWith('/api/tags')) {
586 + handler.resolve(
587 + Response(
588 + requestOptions: options,
589 + statusCode: 200,
590 + data: {
591 + 'models': [
592 + {
593 + 'name': 'qwen3.5:9b',
594 + 'size': 6600000000,
595 + 'details': {
596 + 'parameter_size': '9.7B',
597 + 'quantization_level': 'Q4_K_M',
598 + 'family': 'qwen35',
599 + },
600 + },
601 + ],
602 + },
603 + ),
604 + );
605 + return;
606 + }
607 + if (options.path.endsWith('/api/show')) {
608 + showBodies.add(options.data);
609 + handler.resolve(
610 + Response(
611 + requestOptions: options,
612 + statusCode: 200,
613 + data: {
614 + 'capabilities': ['completion', 'thinking'],
615 + },
616 + ),
617 + );
618 + return;
619 + }
620 + handler.next(options);
621 + },
622 + ),
623 + );
624 +
625 + final service = ServerApiService(dio);
626 + final models = await service.fetchModels(ollamaServer);
627 +
628 + expect(models, hasLength(1));
629 + expect(showBodies, hasLength(1));
630 + final showBody = showBodies.single;
631 + expect(showBody, isA<Map>());
632 + expect((showBody as Map).containsKey('model'), isTrue);
633 + expect(showBody['model'], 'qwen3.5:9b');
634 + expect(showBody.containsKey('name'), isFalse);
635 + });
636 +
637 + test(
638 + 'fetchModels marks Ollama thinking capability with binary off/on',
639 + () async {
640 + final ollamaServer = Server(
641 + id: 'test-ollama',
642 + name: 'Test Ollama',
643 + type: ServerType.ollama,
644 + host: 'localhost',
645 + port: 11434,
646 + createdAt: DateTime.now(),
647 + lastConnectedAt: DateTime.now(),
648 + );
649 +
650 + final interceptor = RoutingInterceptor({
651 + 'GET /api/tags': {
652 + 'models': [
653 + {
654 + 'name': 'qwen3.5:9b',
655 + 'size': 6600000000,
656 + 'details': {
657 + 'parameter_size': '9.7B',
658 + 'quantization_level': 'Q4_K_M',
659 + 'family': 'qwen35',
660 + },
661 + },
662 + {
663 + 'name': 'llama3.2:3b',
664 + 'size': 2000000000,
665 + 'details': {
666 + 'parameter_size': '3B',
667 + 'quantization_level': 'Q4_K_M',
668 + 'family': 'llama',
669 + },
670 + },
671 + ],
672 + },
673 + 'POST /api/show:qwen3.5:9b': {
674 + 'capabilities': ['completion', 'tools', 'thinking'],
675 + 'details': {
676 + 'family': 'qwen35',
677 + 'families': ['qwen35'],
678 + },
679 + },
680 + 'POST /api/show:llama3.2:3b': {
681 + 'capabilities': ['completion'],
682 + },
683 + });
684 +
685 + final service = ServerApiService(Dio()..interceptors.add(interceptor));
686 + final models = await service.fetchModels(ollamaServer);
687 +
688 + expect(models, hasLength(2));
689 + final byId = {for (final m in models) m.id: m};
690 + final thinker = byId['qwen3.5:9b']!;
691 + expect(thinker.supportsReasoning, isTrue);
692 + expect(thinker.supportedReasoningEfforts, ['off', 'on']);
693 + expect(thinker.reasoningMandatory, isFalse);
694 + expect(thinker.supportsToolUse, isTrue);
695 +
696 + final plain = byId['llama3.2:3b']!;
697 + expect(plain.supportsReasoning, isFalse);
698 + expect(plain.supportedReasoningEfforts, isNull);
699 + expect(plain.reasoningMandatory, isFalse);
700 + },
701 + );
702 +
703 + test(
704 + 'fetchModels treats Ollama gptoss family as mandatory granular',
705 + () async {
706 + final ollamaServer = Server(
707 + id: 'test-ollama',
708 + name: 'Test Ollama',
709 + type: ServerType.ollama,
710 + host: 'localhost',
711 + port: 11434,
712 + createdAt: DateTime.now(),
713 + lastConnectedAt: DateTime.now(),
714 + );
715 +
716 + final interceptor = RoutingInterceptor({
717 + 'GET /api/tags': {
718 + 'models': [
719 + {
720 + 'name': 'gpt-oss:20b',
721 + 'size': 13700000000,
722 + 'details': {
723 + 'parameter_size': '20.9B',
724 + 'quantization_level': 'MXFP4',
725 + 'family': 'gptoss',
726 + },
727 + },
728 + ],
729 + },
730 + 'POST /api/show:gpt-oss:20b': {
731 + 'capabilities': ['completion'],
732 + 'details': {
733 + 'family': 'gptoss',
734 + 'families': ['gptoss'],
735 + },
736 + },
737 + });
738 +
739 + final service = ServerApiService(Dio()..interceptors.add(interceptor));
740 + final models = await service.fetchModels(ollamaServer);
741 +
742 + expect(models, hasLength(1));
743 + final model = models.single;
744 + expect(model.supportsReasoning, isTrue);
745 + expect(model.supportedReasoningEfforts, ['low', 'medium', 'high']);
746 + expect(model.reasoningMandatory, isTrue);
747 + },
748 + );
749 +
750 + test(
751 + 'fetchModels handles absent or malformed Ollama capabilities',
752 + () async {
753 + final ollamaServer = Server(
754 + id: 'test-ollama',
755 + name: 'Test Ollama',
756 + type: ServerType.ollama,
757 + host: 'localhost',
758 + port: 11434,
759 + createdAt: DateTime.now(),
760 + lastConnectedAt: DateTime.now(),
761 + );
762 +
763 + final interceptor = RoutingInterceptor({
764 + 'GET /api/tags': {
765 + 'models': [
766 + {'name': 'missing-caps:latest', 'size': 1000, 'details': {}},
767 + {'name': 'malformed-caps:latest', 'size': 1000, 'details': {}},
768 + {'name': 'failing-caps:latest', 'size': 1000, 'details': {}},
769 + ],
770 + },
771 + 'POST /api/show:missing-caps:latest': {'details': {}},
772 + 'POST /api/show:malformed-caps:latest': {'capabilities': 'thinking'},
773 + // No mock for failing-caps:latest — enrichment must fall back to the
774 + // base model instead of failing the whole fetch.
775 + });
776 +
777 + final service = ServerApiService(Dio()..interceptors.add(interceptor));
778 + final models = await service.fetchModels(ollamaServer);
779 +
780 + expect(models, hasLength(3));
781 + for (final model in models) {
782 + expect(model.supportsReasoning, isFalse);
783 + expect(model.supportedReasoningEfforts, isNull);
784 + expect(model.reasoningMandatory, isFalse);
785 + }
786 + },
787 + );
788 +
789 test('unloadModel for Ollama surfaces failures', () async {
790 final ollamaServer = Server(
791 id: 'test-ollama',