2 files changed
+92
-93
lib/features/chat/data/chat_service.dart
+13
-18
@@ -221,24 +221,19 @@ class LMStudioChatService implements ChatService {
221
if (params.repeatPenalty != null) {
222
body['repeat_penalty'] = params.repeatPenalty;
223
}
224
- // LM Studio's native /api/v1/chat expects `reasoning` as a *string*
225
- // ('off' | 'low' | 'medium' | 'high' | 'xhigh'), and does NOT accept
226
- // the OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
227
- // keys that the shared [_applyReasoningControl] writes. Sending the
228
- // object form causes HTTP 400 `invalid_request` for every
229
- // reasoning-capable model (and ChatReasoningConfig defaults to
230
- // enabled). We therefore apply reasoning in the native shape here.
231
- //
232
- // We send the user's selected [ReasoningEffort] (already snapped via
233
- // [resolveEffortForModel] to whatever the active model advertises)
234
- // rather than the generic 'on', because newer models — e.g.
235
- // meta/muse-glimmer — reject 'on' with HTTP 400 and only accept the
236
- // enum effort values. Older models that don't advertise efforts get
237
- // the classic 'low'/'medium'/'high' fallback, which they accept.
238
- if (params.reasoningEnabled == false) {
239
- body['reasoning'] = 'off';
240
- } else if (params.reasoningEnabled == true) {
241
- body['reasoning'] = params.reasoningEffort.apiValue;
224
+ // LM Studio's native /api/v1/chat rejects the OpenAI-compat shape
225
+ // [_applyReasoningControl] writes (object `reasoning`,
226
+ // `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';
237
}
238
if (params.contextLength > 0) {
239
body['context_length'] = params.contextLength;
test/features/chat/lm_studio_mcp_integration_test.dart
+79
-75
@@ -45,6 +45,53 @@ class CapturingStreamInterceptor extends Interceptor {
45
}
46
}
47
48
+/// Asserts that [body] uses the native /api/v1/chat reasoning shape:
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
+) {
56
+ expect(
57
+ body['reasoning'],
58
+ isA<String>(),
59
+ reason: 'native endpoint expects reasoning as a string',
60
+ );
61
+ expect(body['reasoning'], equals(expected));
62
+ expect(body.containsKey('reasoning_effort'), isFalse);
63
+ expect(body.containsKey('think'), isFalse);
64
+ expect(body.containsKey('enable_thinking'), isFalse);
65
+}
66
+
67
+/// Builds the minimal successful SSE stub for a single model turn —
68
+/// chat.start + chat.end — keyed by [modelId].
69
+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
+ ];
77
+
78
+/// Sets up an LM Studio chat harness with a captured request and the
79
+/// minimal SSE stub for [modelId]. The returned [CapturingStreamInterceptor]
80
+/// has [RequestOptions.data] populated after [service] processes the
81
+/// request.
82
+({CapturingStreamInterceptor interceptor, Dio dio, LMStudioChatService service})
83
+ buildLmStudioHarness({String modelId = 'test-model'}) {
84
+ final interceptor = CapturingStreamInterceptor(
85
+ _lmStudioMinimalStub(modelId),
86
+ );
87
+ final dio = Dio()..interceptors.add(interceptor);
88
+ return (
89
+ interceptor: interceptor,
90
+ dio: dio,
91
+ service: LMStudioChatService(dio),
92
+ );
93
+}
94
+
95
void main() {
96
group('LM Studio MCP Integration Tests (native /api/v1/chat)', () {
97
final server = Server(
@@ -170,23 +217,14 @@ void main() {
217
test(
218
'reasoning control uses native string shape, not OpenAI-compat object',
219
() async {
173
- // Regression: the shared _applyReasoningControl writes `reasoning` as an
174
- // object and extra OpenAI-compat keys (reasoning_effort/think/
220
+ // Regression: the shared _applyReasoningControl writes `reasoning` as
221
+ // an object and extra OpenAI-compat keys (reasoning_effort/think/
222
// enable_thinking). LM Studio's native /api/v1/chat rejects all of
223
// those with HTTP 400, so LMStudioChatService must emit the native
224
// string form ('off'|'low'|'medium'|'high'|'xhigh') only.
178
- final interceptor = CapturingStreamInterceptor([
179
- 'event: chat.start',
180
- 'data: {"type":"chat.start","model_instance_id":"test-model"}',
181
- '',
182
- 'event: chat.end',
183
- 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
184
- '',
185
- ]);
186
- final dio = Dio()..interceptors.add(interceptor);
187
- final service = LMStudioChatService(dio);
225
+ final harness = buildLmStudioHarness();
226
189
- await service
227
+ await harness.service
228
.sendMessage(
229
server: server,
230
modelId: 'test-model',
@@ -198,22 +236,11 @@ void main() {
236
)
237
.toList();
238
201
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
202
-
203
- // `reasoning` must be a string, never an object.
204
- expect(
205
- body['reasoning'],
206
- isA<String>(),
207
- reason: 'native endpoint expects reasoning as a string',
208
- );
209
- // Must send the actual effort enum value (e.g. 'medium'), not the
239
+ final body =
240
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
241
+ // Must send the actual effort enum value ('medium'), not the
242
// generic 'on' which newer models like meta/muse-glimmer reject.
211
- expect(body['reasoning'], equals('medium'));
212
-
213
- // The OpenAI-compatible reasoning keys must NOT be sent.
214
- expect(body.containsKey('reasoning_effort'), isFalse);
215
- expect(body.containsKey('think'), isFalse);
216
- expect(body.containsKey('enable_thinking'), isFalse);
243
+ expectNativeReasoningShape(body, 'medium');
244
},
245
);
246
@@ -223,27 +250,23 @@ void main() {
250
() async {
251
// Regression for https://github.com/abdulmominsakib/localmind/issues/75
252
// meta/muse-glimmer accepts only 'low'|'medium'|'high'|'xhigh' and
226
- // rejects 'on'/'off' with HTTP 400. The native LM Studio chat
227
- // service must forward the user's selected effort instead of the
228
- // hard-coded 'on'.
229
- for (final effort in ReasoningEffort.values) {
230
- // 'max' isn't part of the LM Studio enum, so we only assert the
231
- // values the server actually accepts. Skip if the enum value
232
- // exceeds what LM Studio supports.
233
- if (effort == ReasoningEffort.max) continue;
234
-
235
- final interceptor = CapturingStreamInterceptor([
236
- 'event: chat.start',
237
- 'data: {"type":"chat.start","model_instance_id":"meta/muse-glimmer"}',
238
- '',
239
- 'event: chat.end',
240
- 'data: {"type":"chat.end","result":{"model_instance_id":"meta/muse-glimmer","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
241
- '',
242
- ]);
243
- final dio = Dio()..interceptors.add(interceptor);
244
- final service = LMStudioChatService(dio);
245
-
246
- await service
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
+ ];
263
+
264
+ for (final effort in bracketingEfforts) {
265
+ final harness = buildLmStudioHarness(
266
+ modelId: 'meta/muse-glimmer',
267
+ );
268
+
269
+ await harness.service
270
.sendMessage(
271
server: server,
272
modelId: 'meta/muse-glimmer',
@@ -255,34 +278,17 @@ void main() {
278
)
279
.toList();
280
258
- final body = interceptor.capturedRequest!.data
281
+ final body = harness.interceptor.capturedRequest!.data
282
as Map<String, dynamic>;
260
- expect(
261
- body['reasoning'],
262
- equals(effort.apiValue),
263
- reason:
264
- 'effort $effort should be forwarded as its enum string',
265
- );
266
- // And it must never be the generic 'on' that muse-glimmer
267
- // rejects.
268
- expect(body['reasoning'], isNot(equals('on')));
283
+ expectNativeReasoningShape(body, effort.apiValue);
284
}
285
},
286
);
287
288
test('reasoning disabled sends native "off" string', () async {
274
- final interceptor = CapturingStreamInterceptor([
275
- 'event: chat.start',
276
- 'data: {"type":"chat.start","model_instance_id":"test-model"}',
277
- '',
278
- 'event: chat.end',
279
- 'data: {"type":"chat.end","result":{"model_instance_id":"test-model","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
280
- '',
281
- ]);
282
- final dio = Dio()..interceptors.add(interceptor);
283
- final service = LMStudioChatService(dio);
289
+ final harness = buildLmStudioHarness();
290
285
- await service
291
+ await harness.service
292
.sendMessage(
293
server: server,
294
modelId: 'test-model',
@@ -291,11 +297,9 @@ void main() {
297
)
298
.toList();
299
294
- final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
295
- expect(body['reasoning'], equals('off'));
296
- expect(body.containsKey('reasoning_effort'), isFalse);
297
- expect(body.containsKey('think'), isFalse);
298
- expect(body.containsKey('enable_thinking'), isFalse);
300
+ final body =
301
+ harness.interceptor.capturedRequest!.data as Map<String, dynamic>;
302
+ expectNativeReasoningShape(body, 'off');
303
});
304
305
test(