12 files changed
+580
-91
lib/features/chat/data/chat_service.dart
+16
-10
@@ -22,6 +22,7 @@ import 'tools/adapters/openrouter_tool_adapter.dart';
22
import 'tools/adapters/ollama_tool_adapter.dart';
23
import 'chat_api_error.dart';
24
import 'chat_error_formatter.dart';
25
+import '../providers/chat_reasoning_providers.dart';
26
27
abstract class ChatService {
28
Stream<ChatResponse> sendMessage({
@@ -224,16 +225,21 @@ class LMStudioChatService implements ChatService {
225
// LM Studio's native /api/v1/chat rejects the OpenAI-compat shape
226
// [_applyReasoningControl] writes (object `reasoning`,
227
// `reasoning_effort`, `think`, `enable_thinking`) with HTTP 400. It
227
- // wants `reasoning` as a string from {'off'|'low'|'medium'|'high'|
228
- // 'xhigh'}. meta/muse-glimmer additionally rejects 'on', so we
229
- // forward `params.reasoningEffort.apiValue` (already snapped via
230
- // [resolveEffortForModel]) rather than a generic 'on'. When the
231
- // model doesn't support reasoning, `reasoningEnabled` is null and
232
- // we omit the key entirely.
233
- if (params.reasoningEnabled != null) {
234
- body['reasoning'] = params.reasoningEnabled!
235
- ? params.reasoningEffort.apiValue
236
- : 'off';
228
+ // wants `reasoning` as a string. The exact allowed values are per-model
229
+ // (`capabilities.reasoning.allowed_options`, e.g. `["off","on"]` for
230
+ // binary models or `["low","medium","high","xhigh"]` for
231
+ // meta/muse-glimmer which rejects both 'on' and 'off'). We therefore
232
+ // resolve via [resolveLmStudioReasoningValue] and omit the key when it
233
+ // returns null (unsupported model, or disabled while `off` isn't
234
+ // allowed — omitting falls back to the server default instead of 400).
235
+ final reasoningValue = resolveLmStudioReasoningValue(
236
+ enabled: params.reasoningEnabled,
237
+ effort: params.reasoningEffort,
238
+ allowedOptions: params.reasoningAllowedOptions,
239
+ defaultOption: params.reasoningDefaultOption,
240
+ );
241
+ if (reasoningValue != null) {
242
+ body['reasoning'] = reasoningValue;
243
}
244
if (params.contextLength > 0) {
245
body['context_length'] = params.contextLength;
lib/features/chat/data/models/chat_parameters.dart
+19
@@ -16,6 +16,17 @@ 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.
24
+ final List<String>? reasoningAllowedOptions;
25
+
26
+ /// Raw LM Studio `reasoning.default` for the active model. Used as a
27
+ /// tie-breaker when snapping efforts.
28
+ final String? reasoningDefaultOption;
29
+
30
const ChatParameters({
31
required this.temperature,
32
required this.topP,
@@ -27,6 +38,8 @@ class ChatParameters {
38
this.repeatPenalty,
39
this.reasoningEnabled,
40
this.reasoningEffort = ReasoningEffort.low,
41
+ this.reasoningAllowedOptions,
42
+ this.reasoningDefaultOption,
43
});
44
45
factory ChatParameters.defaults() => const ChatParameters(
@@ -47,6 +60,8 @@ class ChatParameters {
60
double? repeatPenalty,
61
bool? reasoningEnabled,
62
ReasoningEffort? reasoningEffort,
63
+ List<String>? reasoningAllowedOptions,
64
+ String? reasoningDefaultOption,
65
}) {
66
return ChatParameters(
67
temperature: temperature ?? this.temperature,
@@ -59,6 +74,10 @@ class ChatParameters {
74
repeatPenalty: repeatPenalty ?? this.repeatPenalty,
75
reasoningEnabled: reasoningEnabled ?? this.reasoningEnabled,
76
reasoningEffort: reasoningEffort ?? this.reasoningEffort,
77
+ reasoningAllowedOptions:
78
+ reasoningAllowedOptions ?? this.reasoningAllowedOptions,
79
+ reasoningDefaultOption:
80
+ reasoningDefaultOption ?? this.reasoningDefaultOption,
81
);
82
}
83
}
lib/features/chat/providers/chat_notifier.dart
+4
-1
@@ -1450,8 +1450,11 @@ class ChatNotifier extends Notifier<ChatState> {
1450
final messages = <Message>[];
1451
1452
final reasoningConfig = ref.read(chatReasoningConfigProvider);
1453
+ final reasoningMandatory = selectedModel?.reasoningMandatory ?? false;
1454
final shouldDisableThinking =
1454
- (selectedModel?.supportsReasoning ?? false) && !reasoningConfig.enabled;
1455
+ (selectedModel?.supportsReasoning ?? false) &&
1456
+ !reasoningMandatory &&
1457
+ !reasoningConfig.enabled;
1458
1459
final personaPrompt = _getPersonaSystemPrompt();
1460
var systemContent =
lib/features/chat/providers/chat_params_providers.dart
+2
@@ -76,5 +76,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
76
selectedModel,
77
reasoningConfig.effort,
78
),
79
+ reasoningAllowedOptions: selectedModel?.supportedReasoningEfforts,
80
+ reasoningDefaultOption: selectedModel?.defaultReasoningEffort,
81
);
82
});
lib/features/chat/providers/chat_reasoning_providers.dart
+111
-20
@@ -7,8 +7,10 @@ import '../../models/data/models/model_info.dart';
7
///
8
/// Ordered from lightest to heaviest. OpenRouter advertises a per-model
9
/// subset (e.g. `["minimal","low","medium","high","xhigh"]`) plus a default
10
-/// and whether reasoning is mandatory; non-OpenRouter providers don't, so
11
-/// callers fall back to [low]/[medium]/[high] via [effortsForModel].
10
+/// and whether reasoning is mandatory; LM Studio advertises
11
+/// `capabilities.reasoning.allowed_options` (e.g. `["off","on"]`,
12
+/// `["low","medium","high","xhigh"]`) plus a default. Providers without
13
+/// per-model data fall back to [low]/[medium]/[high] via [effortsForModel].
14
enum ReasoningEffort {
15
minimal,
16
low,
@@ -30,9 +32,12 @@ enum ReasoningEffort {
32
}
33
34
/// Returns the effort levels to offer for a model, in lightest→heaviest
33
-/// order. When [supported] is null/empty (non-OpenRouter providers, or an
34
-/// OpenRouter model that didn't advertise efforts) the classic
35
-/// low/medium/high set is returned so existing behaviour is preserved.
35
+/// order. When [supported] is null/empty (provider didn't advertise efforts)
36
+/// the classic low/medium/high set is returned so existing behaviour is
37
+/// preserved. LM Studio `allowed_options` entries `off`/`on` are not efforts
38
+/// and are filtered out; a binary `["off","on"]` model yields a single
39
+/// placeholder (the picker should be hidden via [hasGranularReasoningChoice]
40
+/// — the request layer sends literal `"on"` for those models).
41
List<ReasoningEffort> effortsForModel(List<String>? supported) {
42
if (supported == null || supported.isEmpty) {
43
return const [
@@ -41,43 +46,129 @@ List<ReasoningEffort> effortsForModel(List<String>? supported) {
46
ReasoningEffort.high,
47
];
48
}
49
+ final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
50
final list = ReasoningEffort.values
45
- .where((e) => supported.contains(e.apiValue))
51
+ .where((e) => normalized.contains(e.apiValue))
52
.toList(growable: false);
47
- // Shouldn't happen (an OpenRouter model advertising only efforts we don't
48
- // model), but fall back to the classic set rather than rendering nothing.
49
- if (list.isEmpty) {
50
- return const [
51
- ReasoningEffort.low,
52
- ReasoningEffort.medium,
53
- ReasoningEffort.high,
54
- ];
53
+ if (list.isNotEmpty) return list;
54
+ if (normalized.contains('on')) {
55
+ // Binary on/off model (e.g. LM Studio ["off","on"]): no granular choice.
56
+ return const [ReasoningEffort.medium];
57
}
56
- return list;
58
+ // Shouldn't happen (a model advertising only efforts we don't
59
+ // model), but fall back to the classic set rather than rendering nothing.
60
+ return const [
61
+ ReasoningEffort.low,
62
+ ReasoningEffort.medium,
63
+ ReasoningEffort.high,
64
+ ];
65
+}
66
+
67
+/// True when [supported] advertises at least one granular effort
68
+/// (`minimal`/`low`/`medium`/`high`/`xhigh`/`max`). Binary LM Studio models
69
+/// (`["off","on"]`) return false — the UI should hide the effort picker and
70
+/// offer only the on/off toggle.
71
+bool hasGranularReasoningChoice(List<String>? supported) {
72
+ if (supported == null || supported.isEmpty) return true;
73
+ final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
74
+ return ReasoningEffort.values.any((e) => normalized.contains(e.apiValue));
75
}
76
77
/// Resolves the effort to actually send for [model], snapping [current] to
78
/// the model's advertised default when [current] isn't supported. Used when
79
/// building request params so a stale UI config never produces an invalid
80
/// `reasoning_effort` (e.g. `low` for a model that only supports `high`).
81
+/// Comparison is case-insensitive so LM Studio `allowed_options` (which may
82
+/// include `off`/`on`) interoperate with OpenRouter `supported_efforts`.
83
+/// Binary on/off models (no granular effort advertised) keep [current]
84
+/// unchanged — the effort value is irrelevant there because the request
85
+/// layer sends literal `"on"`.
86
ReasoningEffort resolveEffortForModel(
87
ModelInfo? model,
88
ReasoningEffort current,
89
) {
90
final supported = model?.supportedReasoningEfforts;
91
if (supported == null || supported.isEmpty) return current;
69
- if (supported.contains(current.apiValue)) return current;
70
- final defaultVal = model?.defaultReasoningEffort;
71
- if (defaultVal != null && supported.contains(defaultVal)) {
72
- return ReasoningEffort.fromApiValue(defaultVal);
92
+ final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
93
+ if (normalized.contains(current.apiValue)) return current;
94
+ if (!hasGranularReasoningChoice(supported)) return current;
95
+ final defaultVal = model?.defaultReasoningEffort?.trim().toLowerCase();
96
+ if (defaultVal != null && normalized.contains(defaultVal)) {
97
+ // Default may be "on"/"off" for LM Studio binary models; only snap to
98
+ // it when it is an actual effort value.
99
+ for (final e in ReasoningEffort.values) {
100
+ if (e.apiValue == defaultVal) return e;
101
+ }
102
}
103
// Pick the heaviest supported effort we model, else the first advertised.
104
for (final e in ReasoningEffort.values.reversed) {
76
- if (supported.contains(e.apiValue)) return e;
105
+ if (normalized.contains(e.apiValue)) return e;
106
}
107
return ReasoningEffort.fromApiValue(supported.first);
108
}
109
110
+/// Resolves the exact `reasoning` string for LM Studio's native
111
+/// `/api/v1/chat`, or null when the key should be omitted (server default).
112
+///
113
+/// - [enabled] null (model doesn't support reasoning) → null (omit).
114
+/// - Disabled + `off` advertised (or unknown caps) → `'off'`.
115
+/// - Disabled + `off` NOT advertised (e.g. `meta/muse-glimmer` with
116
+/// `["low","medium","high","xhigh"]`) → null (omit). Sending `'off'`
117
+/// there yields HTTP 400, so omitting lets the server use its default
118
+/// instead of failing the whole request (fixes #75 follow-up).
119
+/// - Enabled → the granular effort when advertised; literal `'on'` for
120
+/// binary `["off","on"]` models; `minimal`→`low` and `max`→`xhigh`
121
+/// normalization for LM Studio, which never advertises those two.
122
+String? resolveLmStudioReasoningValue({
123
+ required bool? enabled,
124
+ required ReasoningEffort effort,
125
+ List<String>? allowedOptions,
126
+ String? defaultOption,
127
+}) {
128
+ if (enabled == null) return null;
129
+ final allowed = allowedOptions
130
+ ?.map((e) => e.trim().toLowerCase())
131
+ .where((e) => e.isNotEmpty)
132
+ .toSet();
133
+ final def = defaultOption?.trim().toLowerCase();
134
+
135
+ if (!enabled) {
136
+ if (allowed == null || allowed.isEmpty) return 'off';
137
+ if (allowed.contains('off')) return 'off';
138
+ return null;
139
+ }
140
+
141
+ // Map the two efforts LM Studio never advertises to the nearest value it
142
+ // does understand.
143
+ var candidate = effort.apiValue;
144
+ if (effort == ReasoningEffort.minimal) candidate = 'low';
145
+ if (effort == ReasoningEffort.max) candidate = 'xhigh';
146
+
147
+ if (allowed == null || allowed.isEmpty) return candidate;
148
+ if (allowed.contains(candidate)) return candidate;
149
+
150
+ final granularAllowed = ReasoningEffort.values
151
+ .where((e) => allowed.contains(e.apiValue))
152
+ .toList(growable: false);
153
+
154
+ if (granularAllowed.isNotEmpty) {
155
+ // Snap out-of-range choices to the nearest advertised end; prefer the
156
+ // server default when it is granular.
157
+ if (candidate == 'low') return granularAllowed.first.apiValue;
158
+ if (candidate == 'xhigh') return granularAllowed.last.apiValue;
159
+ if (def != null && def != 'on' && def != 'off' && allowed.contains(def)) {
160
+ return def;
161
+ }
162
+ // Rare non-contiguous case (e.g. medium requested, only low+high
163
+ // allowed): fall back to the heaviest, matching resolveEffortForModel.
164
+ return granularAllowed.last.apiValue;
165
+ }
166
+
167
+ if (allowed.contains('on')) return 'on';
168
+ if (def != null && allowed.contains(def)) return def;
169
+ return null;
170
+}
171
+
172
class ChatReasoningConfig {
173
const ChatReasoningConfig({
174
this.enabled = true,
lib/features/chat/providers/model_selection_providers.dart
+6
-1
@@ -134,7 +134,12 @@ class SelectedModelNotifier extends Notifier<ModelInfo?> {
134
notifier.setEnabled(true);
135
}
136
final supported = model.supportedReasoningEfforts;
137
- if (supported != null && !supported.contains(reasoning.effort.apiValue)) {
137
+ if (supported == null || supported.isEmpty) return;
138
+ // Binary on/off models have no granular choice; leave the effort alone
139
+ // (the request layer sends literal "on").
140
+ if (!hasGranularReasoningChoice(supported)) return;
141
+ final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
142
+ if (!normalized.contains(reasoning.effort.apiValue)) {
143
notifier.setEffort(resolveEffortForModel(model, reasoning.effort));
144
}
145
}
lib/features/models/components/thinking_mode_chip.dart
+8
-2
@@ -55,9 +55,15 @@ class ThinkingModeChip extends ConsumerWidget {
55
final notifier = ref.read(chatReasoningConfigProvider.notifier);
56
57
// Mandatory-reasoning models can't be turned off, so the toggle side is
58
- // non-interactive and the effort picker stays visible.
58
+ // non-interactive and the effort picker stays visible. Binary on/off
59
+ // models (e.g. LM Studio ["off","on"]) have no granular choice, so the
60
+ // picker is hidden and the chip is a pure toggle (the request layer
61
+ // sends literal "on").
62
final canDisable = !(model?.reasoningMandatory ?? false);
60
- final showPicker = config.enabled || !canDisable;
63
+ final hasGranularChoice = hasGranularReasoningChoice(
64
+ model?.supportedReasoningEfforts,
65
+ );
66
+ final showPicker = (config.enabled || !canDisable) && hasGranularChoice;
67
68
final accent = theme.colorScheme.primary;
69
final enabledFg = accent;
lib/features/servers/data/server_api_service.dart
+76
-4
@@ -439,6 +439,9 @@ class ServerApiService {
439
supportsVision: capabilities.supportsVision,
440
supportsReasoning: capabilities.supportsReasoning,
441
supportsToolUse: capabilities.supportsToolUse,
442
+ supportedReasoningEfforts: capabilities.supportedReasoningEfforts,
443
+ defaultReasoningEffort: capabilities.defaultReasoningEffort,
444
+ reasoningMandatory: capabilities.reasoningMandatory,
445
),
446
);
447
}
@@ -454,21 +457,90 @@ class ServerApiService {
457
return null;
458
}
459
457
- ({bool supportsVision, bool supportsReasoning, bool supportsToolUse})
460
+ ({
461
+ bool supportsVision,
462
+ bool supportsReasoning,
463
+ bool supportsToolUse,
464
+ List<String>? supportedReasoningEfforts,
465
+ String? defaultReasoningEffort,
466
+ bool reasoningMandatory,
467
+ })
468
_parseModelCapabilities(dynamic raw) {
469
if (raw is! Map) {
470
return (
471
supportsVision: false,
472
supportsReasoning: false,
473
supportsToolUse: false,
474
+ supportedReasoningEfforts: null,
475
+ defaultReasoningEffort: null,
476
+ reasoningMandatory: false,
477
);
478
}
479
480
final reasoning = raw['reasoning'];
481
+ final supportsVision = raw['vision'] == true;
482
+ final supportsToolUse = raw['trained_for_tool_use'] == true;
483
+ if (reasoning is! Map || reasoning.isEmpty) {
484
+ return (
485
+ supportsVision: supportsVision,
486
+ supportsReasoning: false,
487
+ supportsToolUse: supportsToolUse,
488
+ supportedReasoningEfforts: null,
489
+ defaultReasoningEffort: null,
490
+ reasoningMandatory: false,
491
+ );
492
+ }
493
+
494
+ // LM Studio native shape: `reasoning: {allowed_options: [...],
495
+ // default: ...}`. Examples: ["off","on"], ["on"] (mandatory),
496
+ // ["low","medium","high","xhigh"] (e.g. meta/muse-glimmer, mandatory
497
+ // because "off" is absent). Preserve the raw list (lower-cased) so the
498
+ // chat layer can send exactly what the server advertises instead of a
499
+ // generic "on"/"off" it may reject with HTTP 400.
500
+ final allowedRaw = reasoning['allowed_options'];
501
+ List<String>? allowedOptions;
502
+ if (allowedRaw is List) {
503
+ final parsed = allowedRaw
504
+ .map((e) => e.toString().trim().toLowerCase())
505
+ .where((e) => e.isNotEmpty)
506
+ .toList(growable: false);
507
+ if (parsed.isNotEmpty) allowedOptions = parsed;
508
+ }
509
+ final defaultOption = reasoning['default']?.toString().trim().toLowerCase();
510
+
511
+ final supportsReasoning = allowedOptions == null
512
+ // Legacy map without allowed_options (or unexpected shape):
513
+ // keep the old "non-empty means supported" behaviour.
514
+ ? true
515
+ // ["off"] alone means no reasoning to enable.
516
+ : allowedOptions.any((o) => o != 'off');
517
+
518
+ final reasoningMandatory =
519
+ supportsReasoning &&
520
+ allowedOptions != null &&
521
+ !allowedOptions.contains('off');
522
+
523
+ if (!supportsReasoning) {
524
+ return (
525
+ supportsVision: supportsVision,
526
+ supportsReasoning: false,
527
+ supportsToolUse: supportsToolUse,
528
+ supportedReasoningEfforts: null,
529
+ defaultReasoningEffort: null,
530
+ reasoningMandatory: false,
531
+ );
532
+ }
533
+
534
return (
469
- supportsVision: raw['vision'] == true,
470
- supportsReasoning: reasoning is Map && reasoning.isNotEmpty,
471
- supportsToolUse: raw['trained_for_tool_use'] == true,
535
+ supportsVision: supportsVision,
536
+ supportsReasoning: supportsReasoning,
537
+ supportsToolUse: supportsToolUse,
538
+ supportedReasoningEfforts: allowedOptions,
539
+ defaultReasoningEffort:
540
+ (defaultOption != null && defaultOption.isNotEmpty)
541
+ ? defaultOption
542
+ : null,
543
+ reasoningMandatory: reasoningMandatory,
544
);
545
}
546
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.7.7+82
19
+version: 1.7.8+83
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
test/features/chat/lm_studio_mcp_integration_test.dart
+135
-52
@@ -49,10 +49,7 @@ class CapturingStreamInterceptor extends Interceptor {
49
/// `reasoning` is a string equal to [expected], and the OpenAI-compat
50
/// keys the shared [_applyReasoningControl] would otherwise write are
51
/// absent. Used by the reasoning-control tests.
52
-void expectNativeReasoningShape(
53
- Map<String, dynamic> body,
54
- Object expected,
55
-) {
52
+void expectNativeReasoningShape(Map<String, dynamic> body, Object expected) {
53
expect(
54
body['reasoning'],
55
isA<String>(),
@@ -67,23 +64,21 @@ void expectNativeReasoningShape(
64
/// Builds the minimal successful SSE stub for a single model turn —
65
/// chat.start + chat.end — keyed by [modelId].
66
List<String> _lmStudioMinimalStub(String modelId) => [
70
- 'event: chat.start',
71
- 'data: {"type":"chat.start","model_instance_id":"$modelId"}',
72
- '',
73
- 'event: chat.end',
74
- 'data: {"type":"chat.end","result":{"model_instance_id":"$modelId","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
75
- '',
76
- ];
67
+ 'event: chat.start',
68
+ 'data: {"type":"chat.start","model_instance_id":"$modelId"}',
69
+ '',
70
+ 'event: chat.end',
71
+ 'data: {"type":"chat.end","result":{"model_instance_id":"$modelId","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
72
+ '',
73
+];
74
75
/// Sets up an LM Studio chat harness with a captured request and the
76
/// minimal SSE stub for [modelId]. The returned [CapturingStreamInterceptor]
77
/// has [RequestOptions.data] populated after [service] processes the
78
/// request.
79
({CapturingStreamInterceptor interceptor, Dio dio, LMStudioChatService service})
83
- buildLmStudioHarness({String modelId = 'test-model'}) {
84
- final interceptor = CapturingStreamInterceptor(
85
- _lmStudioMinimalStub(modelId),
86
- );
80
+buildLmStudioHarness({String modelId = 'test-model'}) {
81
+ final interceptor = CapturingStreamInterceptor(_lmStudioMinimalStub(modelId));
82
final dio = Dio()..interceptors.add(interceptor);
83
return (
84
interceptor: interceptor,
@@ -244,46 +239,134 @@ void main() {
239
},
240
);
241
247
- test(
248
- 'reasoning enabled sends the requested enum value, not "on" '
249
- '(muse-glimmer regression)',
250
- () async {
251
- // Regression for https://github.com/abdulmominsakib/localmind/issues/75
252
- // meta/muse-glimmer accepts only 'low'|'medium'|'high'|'xhigh' and
253
- // rejects 'on' with HTTP 400. The native LM Studio chat service
254
- // must forward the user's selected effort instead of the
255
- // hard-coded 'on'. We bracket the enum with the lightest and
256
- // heaviest advertised efforts — `effort.apiValue` is a constant
257
- // per enum value, so additional iterations would only test the
258
- // same mapping again.
259
- const bracketingEfforts = [
260
- ReasoningEffort.low,
261
- ReasoningEffort.xhigh,
262
- ];
242
+ test('reasoning enabled sends the requested enum value, not "on" '
243
+ '(muse-glimmer regression)', () async {
244
+ // Regression for https://github.com/abdulmominsakib/localmind/issues/75
245
+ // meta/muse-glimmer accepts only 'low'|'medium'|'high'|'xhigh' and
246
+ // rejects 'on' with HTTP 400. The native LM Studio chat service
247
+ // must forward the user's selected effort instead of the
248
+ // hard-coded 'on'. We bracket the enum with the lightest and
249
+ // heaviest advertised efforts — `effort.apiValue` is a constant
250
+ // per enum value, so additional iterations would only test the
251
+ // same mapping again.
252
+ const glimmerAllowed = ['low', 'medium', 'high', 'xhigh'];
253
+ const bracketingEfforts = [ReasoningEffort.low, ReasoningEffort.xhigh];
254
+
255
+ for (final effort in bracketingEfforts) {
256
+ final harness = buildLmStudioHarness(modelId: 'meta/muse-glimmer');
257
+
258
+ await harness.service
259
+ .sendMessage(
260
+ server: server,
261
+ modelId: 'meta/muse-glimmer',
262
+ messages: [userMessage],
263
+ params: ChatParameters.defaults().copyWith(
264
+ reasoningEnabled: true,
265
+ reasoningEffort: effort,
266
+ reasoningAllowedOptions: glimmerAllowed,
267
+ reasoningDefaultOption: 'medium',
268
+ ),
269
+ )
270
+ .toList();
271
+
272
+ final body =
273
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
274
+ expectNativeReasoningShape(body, effort.apiValue);
275
+ }
276
+ });
277
+
278
+ test('reasoning disabled omits key when off is not advertised '
279
+ '(muse-glimmer follow-up)', () async {
280
+ // Follow-up on #75: muse-glimmer also rejects 'off' with HTTP 400
281
+ // (supported: low|medium|high|xhigh). Disabling thinking must omit
282
+ // the key so the server falls back to its default instead of 400.
283
+ final harness = buildLmStudioHarness(modelId: 'meta/muse-glimmer');
284
264
- for (final effort in bracketingEfforts) {
265
- final harness = buildLmStudioHarness(
285
+ await harness.service
286
+ .sendMessage(
287
+ server: server,
288
modelId: 'meta/muse-glimmer',
267
- );
268
-
269
- await harness.service
270
- .sendMessage(
271
- server: server,
272
- modelId: 'meta/muse-glimmer',
273
- messages: [userMessage],
274
- params: ChatParameters.defaults().copyWith(
275
- reasoningEnabled: true,
276
- reasoningEffort: effort,
277
- ),
278
- )
279
- .toList();
289
+ messages: [userMessage],
290
+ params: ChatParameters.defaults().copyWith(
291
+ reasoningEnabled: false,
292
+ reasoningEffort: ReasoningEffort.low,
293
+ reasoningAllowedOptions: const ['low', 'medium', 'high', 'xhigh'],
294
+ reasoningDefaultOption: 'medium',
295
+ ),
296
+ )
297
+ .toList();
298
281
- final body = harness.interceptor.capturedRequest!.data
282
- as Map<String, dynamic>;
283
- expectNativeReasoningShape(body, effort.apiValue);
284
- }
285
- },
286
- );
299
+ final body =
300
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
301
+ expect(body.containsKey('reasoning'), isFalse);
302
+ expect(body.containsKey('reasoning_effort'), isFalse);
303
+ expect(body.containsKey('think'), isFalse);
304
+ expect(body.containsKey('enable_thinking'), isFalse);
305
+ });
306
+
307
+ test('binary on/off model sends literal "on" when enabled', () async {
308
+ final harness = buildLmStudioHarness();
309
+
310
+ await harness.service
311
+ .sendMessage(
312
+ server: server,
313
+ modelId: 'test-model',
314
+ messages: [userMessage],
315
+ params: ChatParameters.defaults().copyWith(
316
+ reasoningEnabled: true,
317
+ reasoningEffort: ReasoningEffort.low,
318
+ reasoningAllowedOptions: const ['off', 'on'],
319
+ reasoningDefaultOption: 'on',
320
+ ),
321
+ )
322
+ .toList();
323
+
324
+ final body =
325
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
326
+ expectNativeReasoningShape(body, 'on');
327
+ });
328
+
329
+ test('minimal/max efforts snap to LM Studio range', () async {
330
+ for (final (effort, expected) in [
331
+ (ReasoningEffort.minimal, 'low'),
332
+ (ReasoningEffort.max, 'xhigh'),
333
+ ]) {
334
+ final harness = buildLmStudioHarness();
335
+
336
+ await harness.service
337
+ .sendMessage(
338
+ server: server,
339
+ modelId: 'test-model',
340
+ messages: [userMessage],
341
+ params: ChatParameters.defaults().copyWith(
342
+ reasoningEnabled: true,
343
+ reasoningEffort: effort,
344
+ ),
345
+ )
346
+ .toList();
347
+
348
+ final body =
349
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
350
+ expectNativeReasoningShape(body, expected);
351
+ }
352
+ });
353
+
354
+ test('unsupported model omits reasoning key entirely', () async {
355
+ final harness = buildLmStudioHarness();
356
+
357
+ await harness.service
358
+ .sendMessage(
359
+ server: server,
360
+ modelId: 'test-model',
361
+ messages: [userMessage],
362
+ params: ChatParameters.defaults(),
363
+ )
364
+ .toList();
365
+
366
+ final body =
367
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
368
+ expect(body.containsKey('reasoning'), isFalse);
369
+ });
370
371
test('reasoning disabled sends native "off" string', () async {
372
final harness = buildLmStudioHarness();
test/features/chat/providers/chat_reasoning_providers_test.dart
+130
@@ -43,6 +43,34 @@ void main() {
43
ReasoningEffort.high,
44
]);
45
});
46
+
47
+ test('binary on/off models yield a single placeholder', () {
48
+ expect(effortsForModel(const ['off', 'on']), [ReasoningEffort.medium]);
49
+ expect(effortsForModel(const ['ON', 'OFF']), [ReasoningEffort.medium]);
50
+ });
51
+
52
+ test('filters off/on out of granular lists', () {
53
+ expect(effortsForModel(const ['off', 'low', 'medium', 'on']), [
54
+ ReasoningEffort.low,
55
+ ReasoningEffort.medium,
56
+ ]);
57
+ });
58
+ });
59
+
60
+ group('hasGranularReasoningChoice', () {
61
+ test('true for unknown and granular lists', () {
62
+ expect(hasGranularReasoningChoice(null), isTrue);
63
+ expect(hasGranularReasoningChoice(const []), isTrue);
64
+ expect(
65
+ hasGranularReasoningChoice(const ['low', 'medium', 'high', 'xhigh']),
66
+ isTrue,
67
+ );
68
+ });
69
+
70
+ test('false for binary on/off lists', () {
71
+ expect(hasGranularReasoningChoice(const ['off', 'on']), isFalse);
72
+ expect(hasGranularReasoningChoice(const ['on']), isFalse);
73
+ });
74
});
75
76
group('resolveEffortForModel', () {
@@ -98,6 +126,108 @@ void main() {
126
);
127
},
128
);
129
+
130
+ test('keeps current for binary on/off models', () {
131
+ final model = modelWithEfforts(const ['off', 'on']);
132
+ expect(
133
+ resolveEffortForModel(model, ReasoningEffort.low),
134
+ ReasoningEffort.low,
135
+ );
136
+ });
137
+
138
+ test('matches case-insensitively for LM Studio caps', () {
139
+ final model = modelWithEfforts(const ['Low', 'Medium']);
140
+ expect(
141
+ resolveEffortForModel(model, ReasoningEffort.low),
142
+ ReasoningEffort.low,
143
+ );
144
+ });
145
+ });
146
+
147
+ group('resolveLmStudioReasoningValue', () {
148
+ test('omits when reasoning is unsupported', () {
149
+ expect(
150
+ resolveLmStudioReasoningValue(
151
+ enabled: null,
152
+ effort: ReasoningEffort.low,
153
+ ),
154
+ isNull,
155
+ );
156
+ });
157
+
158
+ test('legacy null caps send effort or off', () {
159
+ expect(
160
+ resolveLmStudioReasoningValue(
161
+ enabled: true,
162
+ effort: ReasoningEffort.medium,
163
+ ),
164
+ 'medium',
165
+ );
166
+ expect(
167
+ resolveLmStudioReasoningValue(
168
+ enabled: false,
169
+ effort: ReasoningEffort.low,
170
+ ),
171
+ 'off',
172
+ );
173
+ });
174
+
175
+ test('omits off when the model does not advertise it (glimmer)', () {
176
+ expect(
177
+ resolveLmStudioReasoningValue(
178
+ enabled: false,
179
+ effort: ReasoningEffort.low,
180
+ allowedOptions: const ['low', 'medium', 'high', 'xhigh'],
181
+ defaultOption: 'medium',
182
+ ),
183
+ isNull,
184
+ );
185
+ expect(
186
+ resolveLmStudioReasoningValue(
187
+ enabled: true,
188
+ effort: ReasoningEffort.medium,
189
+ allowedOptions: const ['low', 'medium', 'high', 'xhigh'],
190
+ ),
191
+ 'medium',
192
+ );
193
+ });
194
+
195
+ test('binary models send literal on/off', () {
196
+ expect(
197
+ resolveLmStudioReasoningValue(
198
+ enabled: true,
199
+ effort: ReasoningEffort.low,
200
+ allowedOptions: const ['off', 'on'],
201
+ defaultOption: 'on',
202
+ ),
203
+ 'on',
204
+ );
205
+ expect(
206
+ resolveLmStudioReasoningValue(
207
+ enabled: false,
208
+ effort: ReasoningEffort.low,
209
+ allowedOptions: const ['off', 'on'],
210
+ ),
211
+ 'off',
212
+ );
213
+ });
214
+
215
+ test('snaps minimal/max to the LM Studio range', () {
216
+ expect(
217
+ resolveLmStudioReasoningValue(
218
+ enabled: true,
219
+ effort: ReasoningEffort.minimal,
220
+ ),
221
+ 'low',
222
+ );
223
+ expect(
224
+ resolveLmStudioReasoningValue(
225
+ enabled: true,
226
+ effort: ReasoningEffort.max,
227
+ ),
228
+ 'xhigh',
229
+ );
230
+ });
231
});
232
233
group('ReasoningEffort.fromApiValue', () {
test/features/servers/server_api_service_test.dart
+72
@@ -247,6 +247,78 @@ void main() {
247
expect(models[2].supportsReasoning, isFalse);
248
});
249
250
+ test('parses LM Studio allowed_options/default/mandatory', () async {
251
+ final mockData = {
252
+ "models": [
253
+ {
254
+ "key": "meta/muse-glimmer",
255
+ "display_name": "Muse Glimmer",
256
+ "capabilities": {
257
+ "reasoning": {
258
+ "allowed_options": ["low", "medium", "high", "xhigh"],
259
+ "default": "medium",
260
+ },
261
+ },
262
+ },
263
+ {
264
+ "key": "google/gemma-4-e2b",
265
+ "display_name": "Gemma 4 E2B",
266
+ "capabilities": {
267
+ "reasoning": {
268
+ "allowed_options": ["off", "on"],
269
+ "default": "on",
270
+ },
271
+ },
272
+ },
273
+ {
274
+ "key": "deepseek-r1",
275
+ "display_name": "DeepSeek R1",
276
+ "capabilities": {
277
+ "reasoning": {
278
+ "allowed_options": ["on"],
279
+ "default": "on",
280
+ },
281
+ },
282
+ },
283
+ {
284
+ "key": "plain-model",
285
+ "display_name": "Plain",
286
+ "capabilities": {"vision": false},
287
+ },
288
+ ],
289
+ };
290
+
291
+ final dio = Dio()..interceptors.add(TestInterceptor(mockData));
292
+ final service = ServerApiService(dio);
293
+
294
+ final models = await service.fetchModels(testServer);
295
+ expect(models, hasLength(4));
296
+ final byId = {for (final m in models) m.id: m};
297
+
298
+ final glimmer = byId['meta/muse-glimmer']!;
299
+ expect(glimmer.supportsReasoning, isTrue);
300
+ expect(glimmer.reasoningMandatory, isTrue);
301
+ expect(glimmer.supportedReasoningEfforts, [
302
+ 'low',
303
+ 'medium',
304
+ 'high',
305
+ 'xhigh',
306
+ ]);
307
+ expect(glimmer.defaultReasoningEffort, 'medium');
308
+
309
+ final gemma = byId['google/gemma-4-e2b']!;
310
+ expect(gemma.supportsReasoning, isTrue);
311
+ expect(gemma.reasoningMandatory, isFalse);
312
+ expect(gemma.supportedReasoningEfforts, ['off', 'on']);
313
+
314
+ final r1 = byId['deepseek-r1']!;
315
+ expect(r1.supportsReasoning, isTrue);
316
+ expect(r1.reasoningMandatory, isTrue);
317
+
318
+ expect(byId['plain-model']!.supportsReasoning, isFalse);
319
+ expect(byId['plain-model']!.supportedReasoningEfforts, isNull);
320
+ });
321
+
322
test(
323
'handles running models with fallback data array (llama.cpp)',
324
() async {