@tduncan / Cortana / commits / c3026b8

fix(lm-studio): respect per-model reasoning allowed_options and omit off when unsupported

abdulmominsakib committed Sep 3, 2026 at 17:44 UTC c3026b8273d568acb96ec8c0f9e2975be6009ccf
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 {