@tduncan / Cortana / commits / 7d3fb75

fix #75: enforce native string reasoning format for LM Studio and refactor integration tests for improved harness reuse

abdulmominsakib committed Sep 2, 2026 at 18:58 UTC 7d3fb759acdcbca313631114f19fa10303df8e96
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(