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',