main
dart 1,690 lines 56.4 KB
Raw
1 import 'ollama_reasoning_decoder.dart';
2 import 'dart:async';
3 import 'dart:convert';
4 import 'dart:io';
5 import 'dart:isolate';
6 import 'dart:typed_data';
7 import 'package:dio/dio.dart';
8 import '../../servers/data/models/server.dart';
9 import 'models/message.dart';
10 import 'models/chat_parameters.dart';
11 import 'models/mcp_integration.dart';
12 import 'tools/tool_definition.dart';
13 import '../../../core/models/enums.dart';
14 import '../../../core/logger/app_logger.dart';
15 import '../../on_device/data/on_device_gemma_service.dart';
16 import '../../on_device/data/on_device_chat_service.dart';
17 import 'tools/adapters/tool_transport_adapter.dart';
18 import '../utils/attachment_helpers.dart';
19 import '../utils/image_upload_utils.dart';
20 import 'tools/adapters/openai_tool_adapter.dart';
21 import 'tools/adapters/lm_studio_tool_adapter.dart';
22 import 'tools/adapters/openrouter_tool_adapter.dart';
23 import 'tools/adapters/ollama_tool_adapter.dart';
24 import 'chat_api_error.dart';
25 import 'chat_error_formatter.dart';
26 import '../providers/chat_reasoning_providers.dart';
27
28 abstract class ChatService {
29 Stream<ChatResponse> sendMessage({
30 required Server server,
31 required String modelId,
32 required List<Message> messages,
33 required ChatParameters params,
34 List<McpIntegration>? integrations,
35 List<ToolDefinition>? tools,
36 bool continueGeneration = false,
37 });
38
39 void cancelStream();
40
41 static ChatService forServer(
42 ServerType type,
43 Dio dio, {
44 OnDeviceGemmaService? onDeviceGemma,
45 bool imageCompressionEnabled = true,
46 ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
47 }) {
48 switch (type) {
49 case ServerType.lmStudio:
50 return LMStudioChatService(
51 dio,
52 imageCompressionEnabled: imageCompressionEnabled,
53 imageCompressionLevel: imageCompressionLevel,
54 );
55 case ServerType.openAICompatible:
56 case ServerType.requesty:
57 return OpenAICompatibleChatService(
58 dio,
59 imageCompressionEnabled: imageCompressionEnabled,
60 imageCompressionLevel: imageCompressionLevel,
61 );
62 case ServerType.ollama:
63 case ServerType.ollamaCloud:
64 return OllamaChatService(
65 dio,
66 imageCompressionEnabled: imageCompressionEnabled,
67 imageCompressionLevel: imageCompressionLevel,
68 );
69 case ServerType.openRouter:
70 return OpenRouterChatService(
71 dio,
72 imageCompressionEnabled: imageCompressionEnabled,
73 imageCompressionLevel: imageCompressionLevel,
74 );
75 case ServerType.onDevice:
76 if (onDeviceGemma == null) {
77 throw StateError(
78 'OnDeviceGemmaService is required for onDevice server type',
79 );
80 }
81 return OnDeviceChatService(
82 onDeviceGemma,
83 imageCompressionEnabled: imageCompressionEnabled,
84 imageCompressionLevel: imageCompressionLevel,
85 );
86 }
87 }
88 }
89
90 class ChatResponse {
91 final ChatResponseType type;
92 final String? content;
93 final String? reasoningContent;
94 final ToolCallData? toolCall;
95 final InvalidToolCallData? invalidToolCall;
96 final ChatStats? stats;
97
98 const ChatResponse({
99 required this.type,
100 this.content,
101 this.reasoningContent,
102 this.toolCall,
103 this.invalidToolCall,
104 this.stats,
105 });
106 }
107
108 enum ChatResponseType {
109 message,
110 reasoning,
111 toolCall,
112 invalidToolCall,
113 done,
114 processing,
115 timeoutError,
116 error,
117 }
118
119 class ToolCallData {
120 final String tool;
121 final Map<String, dynamic> arguments;
122 final String? output;
123 final ToolProviderInfo? providerInfo;
124
125 const ToolCallData({
126 required this.tool,
127 required this.arguments,
128 this.output,
129 this.providerInfo,
130 });
131 }
132
133 class ToolProviderInfo {
134 final String type;
135 final String? pluginId;
136 final String? serverLabel;
137
138 const ToolProviderInfo({required this.type, this.pluginId, this.serverLabel});
139 }
140
141 class InvalidToolCallData {
142 final String reason;
143 final String? metadataType;
144 final String? toolName;
145 final Map<String, dynamic>? arguments;
146 final ToolProviderInfo? providerInfo;
147
148 const InvalidToolCallData({
149 required this.reason,
150 this.metadataType,
151 this.toolName,
152 this.arguments,
153 this.providerInfo,
154 });
155 }
156
157 class ChatStats {
158 final int inputTokens;
159 final int totalOutputTokens;
160 final int? reasoningOutputTokens;
161 final double? tokensPerSecond;
162 final double? timeToFirstTokenSeconds;
163 final double? modelLoadTimeSeconds;
164
165 const ChatStats({
166 required this.inputTokens,
167 required this.totalOutputTokens,
168 this.reasoningOutputTokens,
169 this.tokensPerSecond,
170 this.timeToFirstTokenSeconds,
171 this.modelLoadTimeSeconds,
172 });
173 }
174
175 class LMStudioChatService implements ChatService {
176 final Dio _dio;
177 final bool imageCompressionEnabled;
178 final ImageCompressionLevel imageCompressionLevel;
179 CancelToken? _cancelToken;
180
181 LMStudioChatService(
182 this._dio, {
183 this.imageCompressionEnabled = true,
184 this.imageCompressionLevel = ImageCompressionLevel.medium,
185 });
186
187 @override
188 Stream<ChatResponse> sendMessage({
189 required Server server,
190 required String modelId,
191 required List<Message> messages,
192 required ChatParameters params,
193 List<McpIntegration>? integrations,
194 List<ToolDefinition>? tools,
195 bool continueGeneration = false,
196 }) async* {
197 _cancelToken = CancelToken();
198
199 // LM Studio's native /api/v1/chat is stateless from the client's
200 // perspective: it does not accept an OpenAI-style `messages` array. We
201 // collapse history into a single string `input` for plain text chats,
202 // or into a typed-input array for chats with image attachments. The
203 // MCP integrations array carries the server-side tool definitions.
204 final useTypedInput = messages.any(
205 (m) =>
206 m.attachmentPaths != null &&
207 m.attachmentPaths!.any(AttachmentHelpers.isImagePath),
208 );
209
210 final String systemPrompt;
211 final input = useTypedInput
212 ? await _buildNativeTypedInput(messages, continueGeneration)
213 : await _buildNativeInputString(messages, continueGeneration);
214 systemPrompt = _extractSystemPrompt(messages) ?? params.systemPrompt ?? '';
215
216 final body = <String, dynamic>{
217 'model': modelId,
218 'input': input,
219 if (systemPrompt.isNotEmpty) 'system_prompt': systemPrompt,
220 if (params.sendTemperature) 'temperature': params.temperature,
221 if (params.sendTopP) 'top_p': params.topP,
222 'max_output_tokens': params.maxTokens,
223 'stream': true,
224 };
225
226 if (params.topK != null) body['top_k'] = params.topK;
227 if (params.minP != null) body['min_p'] = params.minP;
228 if (params.repeatPenalty != null) {
229 body['repeat_penalty'] = params.repeatPenalty;
230 }
231 // LM Studio's native /api/v1/chat rejects the OpenAI-compat shape
232 // [_applyReasoningControl] writes (object `reasoning`,
233 // `reasoning_effort`, `think`, `enable_thinking`) with HTTP 400. It
234 // wants `reasoning` as a string. The exact allowed values are per-model
235 // (`capabilities.reasoning.allowed_options`, e.g. `["off","on"]` for
236 // binary models or `["low","medium","high","xhigh"]` for
237 // meta/muse-glimmer which rejects both 'on' and 'off'). We therefore
238 // resolve via [resolveLmStudioReasoningValue] and omit the key when it
239 // returns null (unsupported model, or disabled while `off` isn't
240 // allowed — omitting falls back to the server default instead of 400).
241 final reasoningValue = resolveLmStudioReasoningValue(
242 enabled: params.reasoningEnabled,
243 effort: params.reasoningEffort,
244 allowedOptions: params.reasoningAllowedOptions,
245 defaultOption: params.reasoningDefaultOption,
246 );
247 if (reasoningValue != null) {
248 body['reasoning'] = reasoningValue;
249 }
250 if (params.contextLength > 0) {
251 body['context_length'] = params.contextLength;
252 }
253
254 // MCP integrations are the only tool path supported by /api/v1/chat.
255 // `tools` (OpenAI-style function definitions) are NOT supported here —
256 // sending them would be silently ignored. The local tool registry is
257 // therefore only useful for non-LM-Studio server types on this build.
258 if (integrations != null && integrations.isNotEmpty) {
259 body['integrations'] = integrations.map((i) => i.toApiJson()).toList();
260 }
261
262 try {
263 final response = await _dio.post<ResponseBody>(
264 server.chatEndpoint,
265 data: body,
266 options: Options(
267 responseType: ResponseType.stream,
268 headers: {
269 'Content-Type': 'application/json',
270 ...buildServerAuthHeaders(server),
271 },
272 ),
273 cancelToken: _cancelToken,
274 );
275
276 final stream = response.data!.stream.cast<List<int>>().transform(
277 utf8.decoder,
278 );
279 String buffer = '';
280 String? currentEventName;
281 final lmStudioToolAdapter = LmStudioToolAdapter();
282
283 await for (final chunk in stream) {
284 buffer += chunk;
285 final lines = buffer.split('\n');
286 buffer = lines.removeLast();
287
288 for (final line in lines) {
289 if (line.isEmpty) {
290 // Blank line = SSE event boundary. Flush any buffered event.
291 currentEventName = null;
292 continue;
293 }
294 if (line.startsWith(':')) {
295 // SSE comment / keep-alive.
296 continue;
297 }
298 if (line.startsWith('event:')) {
299 currentEventName = line.substring(6).trim();
300 continue;
301 }
302 if (!line.startsWith('data:')) continue;
303 final data = line.substring(5).trim();
304 if (data.isEmpty) continue;
305
306 Map<String, dynamic>? json;
307 try {
308 json = jsonDecode(data) as Map<String, dynamic>;
309 } catch (e) {
310 Log.error('LMStudio chat parsing error: $e');
311 continue;
312 }
313
314 // Prefer the SSE `event:` line, fall back to the JSON `type` field.
315 final eventType = currentEventName ?? json['type'] as String? ?? '';
316
317 switch (eventType) {
318 case 'chat.start':
319 // Nothing to do — server is acknowledging the stream.
320 break;
321 case 'message.delta':
322 final content = json['content'] as String?;
323 if (content != null && content.isNotEmpty) {
324 yield ChatResponse(
325 type: ChatResponseType.message,
326 content: content,
327 );
328 }
329 break;
330 case 'message.start':
331 case 'message.end':
332 case 'reasoning.start':
333 case 'reasoning.end':
334 // Boundaries only — content arrives in `*.delta`.
335 break;
336 case 'reasoning.delta':
337 final content = json['content'] as String?;
338 if (content != null && content.isNotEmpty) {
339 yield ChatResponse(
340 type: ChatResponseType.reasoning,
341 reasoningContent: content,
342 );
343 }
344 break;
345 case 'tool_call.start':
346 case 'tool_call.arguments':
347 // Adapter aggregates; final completion is emitted below.
348 lmStudioToolAdapter.consumeEvent(eventType, json);
349 break;
350 case 'tool_call.success':
351 lmStudioToolAdapter.consumeEvent(eventType, json);
352 for (final call
353 in lmStudioToolAdapter.takeServerExecutedCalls()) {
354 yield ChatResponse(
355 type: ChatResponseType.toolCall,
356 toolCall: ToolCallData(
357 tool: call.name,
358 arguments: call.arguments,
359 output: call.output,
360 providerInfo: _providerInfoFromMap(call.providerInfo),
361 ),
362 );
363 }
364 break;
365 case 'tool_call.failure':
366 lmStudioToolAdapter.consumeEvent(eventType, json);
367 for (final call
368 in lmStudioToolAdapter.takeServerExecutedCalls()) {
369 yield ChatResponse(
370 type: ChatResponseType.toolCall,
371 toolCall: ToolCallData(
372 tool: call.name,
373 arguments: call.arguments,
374 output: call.output,
375 providerInfo: _providerInfoFromMap(call.providerInfo),
376 ),
377 );
378 }
379 break;
380 case 'error':
381 final error = json['error'];
382 final message = error is Map
383 ? (error['message'] as String? ?? error.toString())
384 : (error?.toString() ?? 'LM Studio error');
385 yield ChatResponse(
386 type: ChatResponseType.error,
387 content: ChatApiError(
388 message: message,
389 type: error is Map ? error['type'] as String? : null,
390 ).encode(),
391 );
392 return;
393 case 'chat.end':
394 final result = json['result'] as Map<String, dynamic>?;
395 if (result != null) {
396 yield ChatResponse(
397 type: ChatResponseType.done,
398 stats: _extractStats(result),
399 );
400 // Surface any tool_call / invalid_tool_call items present
401 // in the final aggregated output that we may have missed
402 // in the stream.
403 final output = result['output'];
404 if (output is List) {
405 for (final item in output) {
406 if (item is! Map) continue;
407 final m = Map<String, dynamic>.from(item);
408 final t = m['type'];
409 if (t == 'tool_call') {
410 yield ChatResponse(
411 type: ChatResponseType.toolCall,
412 toolCall: ToolCallData(
413 tool: m['tool'] as String? ?? '',
414 arguments:
415 (m['arguments'] as Map?)
416 ?.cast<String, dynamic>() ??
417 {},
418 output: m['output'] as String?,
419 providerInfo: _providerInfoFromMap(
420 (m['provider_info'] as Map?)
421 ?.cast<String, dynamic>(),
422 ),
423 ),
424 );
425 } else if (t == 'invalid_tool_call') {
426 yield ChatResponse(
427 type: ChatResponseType.invalidToolCall,
428 invalidToolCall: InvalidToolCallData(
429 reason: m['reason'] as String? ?? '',
430 metadataType: (m['metadata'] is Map)
431 ? (m['metadata']['type'] as String?)
432 : null,
433 toolName: (m['metadata'] is Map)
434 ? (m['metadata']['tool_name'] as String?)
435 : null,
436 arguments: (m['arguments'] as Map?)
437 ?.cast<String, dynamic>(),
438 providerInfo: _providerInfoFromMap(
439 (m['provider_info'] as Map?)
440 ?.cast<String, dynamic>(),
441 ),
442 ),
443 );
444 }
445 }
446 }
447 } else {
448 yield const ChatResponse(type: ChatResponseType.done);
449 }
450 return;
451 default:
452 // Model load / prompt processing events are surfaced only in
453 // the debug log — they don't carry user-visible content.
454 if (eventType.isNotEmpty) {
455 Log.debug('LMStudio stream event: $eventType');
456 }
457 }
458 }
459 }
460
461 // Stream ended without an explicit chat.end — treat as a normal
462 // completion so the UI doesn't stay stuck in "processing".
463 yield const ChatResponse(type: ChatResponseType.done);
464 } catch (e) {
465 Log.error('LMStudio connection error: $e');
466 yield ChatResponse(
467 type: ChatResponseType.error,
468 content: _handleChatError(e),
469 );
470 return;
471 }
472 }
473
474 /// Builds the native `input` field as a single string, suitable for
475 /// plain-text chats. Conversation history is collapsed into a transcript
476 /// because `/api/v1/chat` does not accept an OpenAI-style `messages`
477 /// array; system prompt is hoisted to a top-level field by the caller.
478 Future<String> _buildNativeInputString(
479 List<Message> messages,
480 bool continueGeneration,
481 ) async {
482 final visible = <Message>[];
483 for (final m in messages) {
484 if (m.role == MessageRole.system) continue;
485 visible.add(m);
486 }
487 // Drop trailing empty assistant (prefill) unless we're continuing it.
488 if (!continueGeneration && visible.isNotEmpty) {
489 while (visible.isNotEmpty &&
490 visible.last.role == MessageRole.assistant &&
491 visible.last.content.isEmpty) {
492 visible.removeLast();
493 }
494 }
495 final buf = StringBuffer();
496 for (final m in visible) {
497 final tag = switch (m.role) {
498 MessageRole.user => 'User',
499 MessageRole.assistant => 'Assistant',
500 MessageRole.tool => 'Tool',
501 MessageRole.system => 'System',
502 };
503 final textContent = await _messageTextWithAttachments(m);
504 buf.writeln('$tag: $textContent');
505 }
506 return buf.toString().trimRight();
507 }
508
509 /// Builds the native `input` field as an array of typed input items.
510 /// Used when at least one image attachment is present.
511 Future<List<Map<String, dynamic>>> _buildNativeTypedInput(
512 List<Message> messages,
513 bool continueGeneration,
514 ) async {
515 final items = <Map<String, dynamic>>[];
516 final visible = <Message>[];
517 for (final m in messages) {
518 if (m.role == MessageRole.system) continue;
519 visible.add(m);
520 }
521 if (!continueGeneration && visible.isNotEmpty) {
522 while (visible.isNotEmpty &&
523 visible.last.role == MessageRole.assistant &&
524 visible.last.content.isEmpty) {
525 visible.removeLast();
526 }
527 }
528 for (final m in visible) {
529 final textContent = await _messageTextWithAttachments(m);
530 if (textContent.isNotEmpty) {
531 items.add({'type': 'text', 'content': textContent});
532 }
533 items.addAll(await _imageItemsFor(m));
534 }
535 return items;
536 }
537
538 Future<List<Map<String, dynamic>>> _imageItemsFor(Message m) async {
539 final paths = m.attachmentPaths ?? const <String>[];
540 final items = <Map<String, dynamic>>[];
541 for (final path in paths) {
542 if (!AttachmentHelpers.isImagePath(path)) continue;
543 try {
544 final file = File(path);
545 if (!await file.exists()) continue;
546 final fileBytes = await ImageUploadUtils.prepareImageBytes(
547 file,
548 enabled: imageCompressionEnabled,
549 level: imageCompressionLevel,
550 );
551 final base64Image = await Isolate.run(() {
552 try {
553 return base64Encode(fileBytes);
554 } catch (_) {
555 return null;
556 }
557 });
558 if (base64Image != null) {
559 items.add({
560 'type': 'image',
561 'data_url': 'data:image/png;base64,$base64Image',
562 });
563 }
564 } catch (e) {
565 Log.error('Failed to read attachment for LMStudio native format: $e');
566 }
567 }
568 return items;
569 }
570
571 /// Builds the text portion of a typed input item, extracting supported
572 /// document contents into the message text so the model actually receives
573 /// them. Image attachments are handled separately by [_imageItemsFor].
574 Future<String> _messageTextWithAttachments(Message m) async {
575 var textContent = m.content;
576 final paths = m.attachmentPaths ?? const <String>[];
577 for (final path in paths) {
578 if (!AttachmentHelpers.isDocumentPath(path)) continue;
579 final text = await AttachmentHelpers.readDocumentFile(path);
580 if (text == null) continue;
581 final fileName = AttachmentHelpers.fileNameOf(path);
582 if (fileName.isEmpty) continue;
583 textContent = AttachmentHelpers.appendTextAttachment(
584 textContent,
585 fileName,
586 text,
587 );
588 }
589 return textContent;
590 }
591
592 String? _extractSystemPrompt(List<Message> messages) {
593 for (final m in messages) {
594 if (m.role == MessageRole.system) return m.content;
595 }
596 return null;
597 }
598
599 ChatStats? _extractStats(Map<String, dynamic> result) {
600 final stats = result['stats'] as Map<String, dynamic>?;
601 if (stats == null) return null;
602 return ChatStats(
603 inputTokens: (stats['input_tokens'] as num?)?.toInt() ?? 0,
604 totalOutputTokens: (stats['total_output_tokens'] as num?)?.toInt() ?? 0,
605 reasoningOutputTokens: (stats['reasoning_output_tokens'] as num?)
606 ?.toInt(),
607 tokensPerSecond: (stats['tokens_per_second'] as num?)?.toDouble(),
608 timeToFirstTokenSeconds: (stats['time_to_first_token_seconds'] as num?)
609 ?.toDouble(),
610 modelLoadTimeSeconds: (stats['model_load_time_seconds'] as num?)
611 ?.toDouble(),
612 );
613 }
614
615 ToolProviderInfo? _providerInfoFromMap(Map<String, dynamic>? map) {
616 if (map == null) return null;
617 return ToolProviderInfo(
618 type: map['type'] as String? ?? '',
619 pluginId: map['plugin_id'] as String?,
620 serverLabel: map['server_label'] as String?,
621 );
622 }
623
624 @override
625 void cancelStream() {
626 _cancelToken?.cancel('User cancelled');
627 }
628 }
629
630 class OpenAICompatibleChatService implements ChatService {
631 final Dio _dio;
632 final bool imageCompressionEnabled;
633 final ImageCompressionLevel imageCompressionLevel;
634 CancelToken? _cancelToken;
635 Timer? _timeoutTimer;
636
637 OpenAICompatibleChatService(
638 this._dio, {
639 this.imageCompressionEnabled = true,
640 this.imageCompressionLevel = ImageCompressionLevel.medium,
641 });
642
643 @override
644 Stream<ChatResponse> sendMessage({
645 required Server server,
646 required String modelId,
647 required List<Message> messages,
648 required ChatParameters params,
649 List<McpIntegration>? integrations,
650 List<ToolDefinition>? tools,
651 bool continueGeneration = false,
652 }) async* {
653 _cancelToken = CancelToken();
654 final toolAdapter = OpenAiToolAdapter();
655
656 final apiMessages = await Future.wait(
657 messages.map(
658 (m) => _buildOpenAiApiMessage(
659 m,
660 imageCompressionEnabled: imageCompressionEnabled,
661 imageCompressionLevel: imageCompressionLevel,
662 ),
663 ),
664 );
665 // Strip trailing empty assistant prefill (or normalise it when
666 // continuing generation) to avoid "prefill incompatible with
667 // enable_thinking" errors from servers running thinking models.
668 _normalizeAssistantPrefill(apiMessages, continueGeneration);
669
670 final body = <String, dynamic>{
671 'model': modelId,
672 'messages': apiMessages,
673 if (params.sendTemperature) 'temperature': params.temperature,
674 if (params.sendTopP) 'top_p': params.topP,
675 'max_tokens': params.maxTokens,
676 'stream': true,
677 };
678 _applyOpenAICompatibleReasoningControl(body, params);
679
680 if (integrations != null && integrations.isNotEmpty) {
681 body['integrations'] = integrations.map((i) => i.toApiJson()).toList();
682 }
683
684 if (tools != null && tools.isNotEmpty) {
685 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
686 if (toolsPayload.containsKey('tools')) {
687 body['tools'] = toolsPayload['tools'];
688 }
689 }
690
691 Log.debug(
692 'OpenAICompatible: Sending request to ${server.chatEndpoint} with model: $modelId',
693 );
694
695 try {
696 final response = await _dio.post<ResponseBody>(
697 server.chatEndpoint,
698 data: body,
699 options: Options(
700 responseType: ResponseType.stream,
701 headers: {
702 'Content-Type': 'application/json',
703 ...buildServerAuthHeaders(server),
704 },
705 ),
706 cancelToken: _cancelToken,
707 );
708
709 final stream = response.data!.stream.cast<List<int>>().transform(
710 utf8.decoder,
711 );
712 String buffer = '';
713 DateTime? lastContentReceived;
714 int emptyDeltaCount = 0;
715 int logCounter = 0;
716 bool timeoutTriggered = false;
717
718 // Start timeout timer
719 _timeoutTimer = Timer(const Duration(seconds: 45), () {
720 timeoutTriggered = true;
721 });
722
723 Log.debug('OpenAICompatible: Starting to receive stream...');
724
725 try {
726 await for (final chunk in stream) {
727 // Check if timeout was triggered
728 if (timeoutTriggered && lastContentReceived == null) {
729 yield const ChatResponse(
730 type: ChatResponseType.timeoutError,
731 content:
732 'Model is taking too long to respond. This may happen with free tier models.',
733 );
734 return;
735 }
736
737 buffer += chunk;
738 final lines = buffer.split('\n');
739 buffer = lines.removeLast();
740
741 for (final line in lines) {
742 logCounter++;
743 if (logCounter % 10 == 0) {
744 Log.debug('OpenAICompatible SSE line #$logCounter');
745 }
746
747 if (line.startsWith('data: ')) {
748 final data = line.substring(6);
749 if (data == '[DONE]') {
750 _timeoutTimer?.cancel();
751 Log.debug('OpenAICompatible: Received [DONE]');
752 for (final call in toolAdapter.takeCompletedCalls()) {
753 yield ChatResponse(
754 type: ChatResponseType.toolCall,
755 toolCall: ToolCallData(
756 tool: call.name,
757 arguments: call.arguments,
758 ),
759 );
760 }
761 yield const ChatResponse(type: ChatResponseType.done);
762 return;
763 }
764 try {
765 final json = jsonDecode(data) as Map<String, dynamic>;
766 toolAdapter.consumeDynamicChunk(json);
767
768 // Check for errors in the response
769 if (json['error'] != null) {
770 _timeoutTimer?.cancel();
771 final errorContent = _formatApiErrorContent(json['error']);
772 Log.error('OpenAICompatible mid-stream error: $errorContent');
773 yield ChatResponse(
774 type: ChatResponseType.error,
775 content: errorContent,
776 );
777 return;
778 }
779
780 final choices = json['choices'] as List<dynamic>?;
781 if (choices == null || choices.isEmpty) {
782 continue;
783 }
784
785 final firstChoice = choices[0] as Map<String, dynamic>?;
786 if (firstChoice == null) continue;
787
788 final delta = firstChoice['delta'];
789 if (delta == null || delta is! Map<String, dynamic>) continue;
790
791 final content = (delta['content'] ?? delta['text']) as String?;
792 final reasoning =
793 (delta['reasoning'] ?? delta['reasoning_content'])
794 as String?;
795 final refusal = delta['refusal'] as String?;
796
797 // Check if content is empty/null (processing but not outputting)
798 final hasContent = content != null && content.isNotEmpty;
799 final hasReasoning = reasoning != null && reasoning.isNotEmpty;
800 final hasRefusal = refusal != null && refusal.isNotEmpty;
801
802 if (!hasContent && !hasReasoning && !hasRefusal) {
803 emptyDeltaCount++;
804 if (emptyDeltaCount % 10 == 0) {
805 yield const ChatResponse(type: ChatResponseType.processing);
806 }
807 } else {
808 emptyDeltaCount = 0;
809 lastContentReceived = DateTime.now();
810
811 if (hasRefusal) {
812 yield ChatResponse(
813 type: ChatResponseType.error,
814 content: 'Refusal: $refusal',
815 );
816 return;
817 }
818 if (hasContent) {
819 yield ChatResponse(
820 type: ChatResponseType.message,
821 content: content,
822 );
823 }
824 if (hasReasoning) {
825 yield ChatResponse(
826 type: ChatResponseType.reasoning,
827 reasoningContent: reasoning,
828 );
829 }
830 }
831 } catch (e) {
832 Log.error('OpenAICompatible parsing error: $e');
833 }
834 }
835 }
836 }
837 } finally {
838 _timeoutTimer?.cancel();
839 }
840
841 // Flush any tool calls accumulated without a [DONE] marker (e.g.
842 // connection drop or server crash before sending [DONE]).
843 for (final call in toolAdapter.takeCompletedCalls()) {
844 yield ChatResponse(
845 type: ChatResponseType.toolCall,
846 toolCall: ToolCallData(tool: call.name, arguments: call.arguments),
847 );
848 }
849 } catch (e) {
850 Log.error('OpenAICompatible connection error: $e');
851 yield ChatResponse(
852 type: ChatResponseType.error,
853 content: _handleChatError(e),
854 );
855 return;
856 } finally {
857 _timeoutTimer?.cancel();
858 }
859
860 yield const ChatResponse(type: ChatResponseType.done);
861 Log.debug('OpenAICompatible: Stream ended');
862 }
863
864 @override
865 void cancelStream() {
866 _timeoutTimer?.cancel();
867 _cancelToken?.cancel('User cancelled');
868 }
869 }
870
871 String _formatOllamaErrorContent(dynamic error) {
872 if (error is String) {
873 return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
874 }
875 if (error is Map) {
876 return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
877 }
878 return ChatApiError(message: error.toString()).encode();
879 }
880
881 Future<String> _handleOllamaChatError(DioException e) async {
882 return ChatErrorFormatter.formatDioException(
883 e,
884 readBody: (data) async {
885 if (data is ResponseBody) {
886 return data.stream.cast<List<int>>().transform(utf8.decoder).join();
887 }
888 if (data is String) return data;
889 return '';
890 },
891 );
892 }
893
894 String? _extractOllamaErrorMessage(dynamic data) {
895 if (data == null) return null;
896 final raw = data is String ? data : jsonEncode(data);
897 if (raw.trim().isEmpty) return null;
898 return ChatErrorFormatter.formatBody(raw);
899 }
900
901 class OllamaChatService implements ChatService {
902 final Dio _dio;
903 final bool imageCompressionEnabled;
904 final ImageCompressionLevel imageCompressionLevel;
905 CancelToken? _cancelToken;
906
907 OllamaChatService(
908 this._dio, {
909 this.imageCompressionEnabled = true,
910 this.imageCompressionLevel = ImageCompressionLevel.medium,
911 });
912
913 @override
914 Stream<ChatResponse> sendMessage({
915 required Server server,
916 required String modelId,
917 required List<Message> messages,
918 required ChatParameters params,
919 List<McpIntegration>? integrations,
920 List<ToolDefinition>? tools,
921 bool continueGeneration = false,
922 }) async* {
923 _cancelToken = CancelToken();
924 final toolAdapter = OllamaToolAdapter();
925 final contentDecoder = OllamaReasoningDecoder();
926 var hasNativeThinking = false;
927
928 final apiMessages = <Map<String, dynamic>>[];
929 for (final message in messages) {
930 apiMessages.add(await _messageToOllamaMapWithImages(message));
931 }
932 // Strip the trailing empty assistant placeholder created for streaming.
933 // Ollama's Qwen 3.5 parser treats any trailing assistant turn as an open
934 // prefill; an empty one misclassifies the generated continuation as
935 // `message.thinking` (see ollama/ollama#17187 and localmind#78).
936 _normalizeAssistantPrefill(apiMessages, continueGeneration);
937
938 final body = <String, dynamic>{
939 'model': modelId,
940 'messages': apiMessages,
941 'stream': true,
942 'options': {
943 if (params.sendTemperature) 'temperature': params.temperature,
944 if (params.sendTopP) 'top_p': params.topP,
945 'num_predict': params.maxTokens,
946 },
947 };
948 _applyOllamaReasoningControl(body, params);
949
950 if (tools != null && tools.isNotEmpty) {
951 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
952 if (toolsPayload.containsKey('tools')) {
953 body['tools'] = toolsPayload['tools'];
954 }
955 }
956
957 final imageCount = apiMessages.fold<int>(0, (count, message) {
958 final images = message['images'];
959 return count + (images is List ? images.length : 0);
960 });
961 Log.debug(
962 'Ollama request: endpoint=${server.chatEndpoint}, model=$modelId, '
963 'messages=${apiMessages.length}, images=$imageCount, '
964 'tools=${tools?.length ?? 0}',
965 );
966
967 try {
968 final response = await _dio.post<ResponseBody>(
969 server.chatEndpoint,
970 data: body,
971 options: Options(
972 responseType: ResponseType.stream,
973 // Ollama returns useful JSON error bodies for 4xx responses. Keep
974 // the stream available so the actual error reaches the UI.
975 validateStatus: (status) => status != null,
976 headers: {
977 'Content-Type': 'application/json',
978 ...buildServerAuthHeaders(server),
979 },
980 ),
981 cancelToken: _cancelToken,
982 );
983
984 final responseBody = response.data!;
985 final statusCode = response.statusCode ?? 0;
986 if (statusCode < 200 || statusCode >= 300) {
987 final rawError = await responseBody.stream
988 .cast<List<int>>()
989 .transform(utf8.decoder)
990 .join();
991 final parsedError = _extractOllamaErrorMessage(rawError);
992 Log.error('Ollama HTTP $statusCode: ${parsedError ?? rawError}');
993 yield ChatResponse(
994 type: ChatResponseType.error,
995 content:
996 parsedError ??
997 (rawError.trim().isNotEmpty
998 ? rawError.trim()
999 : 'Ollama request failed (HTTP $statusCode).'),
1000 );
1001 return;
1002 }
1003
1004 final lines = responseBody.stream
1005 .cast<List<int>>()
1006 .transform(utf8.decoder)
1007 .transform(const LineSplitter());
1008
1009 await for (final line in lines) {
1010 if (line.isNotEmpty) {
1011 try {
1012 final json = jsonDecode(line) as Map<String, dynamic>;
1013 toolAdapter.consumeDynamicChunk(json);
1014
1015 // Ollama surfaces mid-stream errors as a top-level `error`
1016 // field, often alongside HTTP 200. Surface them so the UI
1017 // shows something more specific than the generic
1018 // "unknown error" fallback when — for instance — the
1019 // selected model doesn't actually support images.
1020 final errorField = json['error'];
1021 if (errorField != null) {
1022 yield ChatResponse(
1023 type: ChatResponseType.error,
1024 content: _formatOllamaErrorContent(errorField),
1025 );
1026 return;
1027 }
1028
1029 final message = json['message'];
1030 if (message != null && message is Map<String, dynamic>) {
1031 final content = message['content'] as String?;
1032 final thinking = message['thinking'] as String?;
1033 hasNativeThinking = hasNativeThinking || thinking != null;
1034
1035 if (thinking != null && thinking.isNotEmpty) {
1036 yield ChatResponse(
1037 type: ChatResponseType.reasoning,
1038 reasoningContent: thinking,
1039 );
1040 }
1041 if (content != null && content.isNotEmpty) {
1042 for (final part in contentDecoder.add(
1043 content,
1044 nativeThinking: hasNativeThinking,
1045 )) {
1046 yield ChatResponse(
1047 type: part.isReasoning
1048 ? ChatResponseType.reasoning
1049 : ChatResponseType.message,
1050 content: part.isReasoning ? null : part.text,
1051 reasoningContent: part.isReasoning ? part.text : null,
1052 );
1053 }
1054 }
1055 }
1056 if (json['done'] == true) {
1057 for (final part in contentDecoder.flush()) {
1058 yield ChatResponse(
1059 type: part.isReasoning
1060 ? ChatResponseType.reasoning
1061 : ChatResponseType.message,
1062 content: part.isReasoning ? null : part.text,
1063 reasoningContent: part.isReasoning ? part.text : null,
1064 );
1065 }
1066
1067 for (final call in toolAdapter.takeCompletedCalls()) {
1068 yield ChatResponse(
1069 type: ChatResponseType.toolCall,
1070 toolCall: ToolCallData(
1071 tool: call.name,
1072 arguments: call.arguments,
1073 ),
1074 );
1075 }
1076 yield const ChatResponse(type: ChatResponseType.done);
1077 return;
1078 }
1079 } catch (e) {
1080 Log.error('Ollama chunk parsing error: $e');
1081 }
1082 }
1083 }
1084 } catch (e) {
1085 Log.error('Ollama connection error: $e');
1086 final content = e is DioException
1087 ? await _handleOllamaChatError(e)
1088 : _handleChatError(e);
1089 yield ChatResponse(type: ChatResponseType.error, content: content);
1090 return;
1091 }
1092 for (final part in contentDecoder.flush()) {
1093 yield ChatResponse(
1094 type: part.isReasoning
1095 ? ChatResponseType.reasoning
1096 : ChatResponseType.message,
1097 content: part.isReasoning ? null : part.text,
1098 reasoningContent: part.isReasoning ? part.text : null,
1099 );
1100 }
1101 yield const ChatResponse(type: ChatResponseType.done);
1102 }
1103
1104 Future<Map<String, dynamic>> _messageToOllamaMapWithImages(Message m) async {
1105 var textContent = m.content;
1106 final images = <String>[];
1107
1108 for (final path in m.attachmentPaths ?? const <String>[]) {
1109 if (AttachmentHelpers.isDocumentPath(path)) {
1110 final text = await AttachmentHelpers.readDocumentFile(path);
1111 if (text != null) {
1112 textContent = AttachmentHelpers.appendTextAttachment(
1113 textContent,
1114 AttachmentHelpers.fileNameOf(path),
1115 text,
1116 );
1117 }
1118 continue;
1119 }
1120
1121 if (!AttachmentHelpers.isImagePath(path)) continue;
1122
1123 try {
1124 final file = File(path);
1125 if (!await file.exists()) continue;
1126
1127 final fileBytes = await ImageUploadUtils.prepareImageBytes(
1128 file,
1129 enabled: imageCompressionEnabled,
1130 level: imageCompressionLevel,
1131 );
1132 final base64Image = await Isolate.run(() {
1133 try {
1134 return base64Encode(fileBytes);
1135 } catch (_) {
1136 return null;
1137 }
1138 });
1139
1140 if (base64Image != null) images.add(base64Image);
1141 } catch (e) {
1142 Log.error('Failed to read attachment for Ollama format: $e');
1143 }
1144 }
1145
1146 final map = <String, dynamic>{
1147 'role': _roleToString(m.role),
1148 'content': textContent,
1149 };
1150 if (images.isNotEmpty) map['images'] = images;
1151
1152 if (m.role == MessageRole.tool && m.toolCallId != null) {
1153 map['tool_call_id'] = m.toolCallId;
1154 }
1155 if (m.role == MessageRole.assistant &&
1156 m.toolCalls != null &&
1157 m.toolCalls!.isNotEmpty) {
1158 map['tool_calls'] = m.toolCalls!.map((tc) {
1159 return {
1160 'id': tc.id,
1161 'type': 'function',
1162 'function': {
1163 'name': tc.toolName,
1164 'arguments': jsonEncode(tc.arguments),
1165 },
1166 };
1167 }).toList();
1168 }
1169 return map;
1170 }
1171
1172 @override
1173 void cancelStream() {
1174 _cancelToken?.cancel('User cancelled');
1175 }
1176 }
1177
1178 class OpenRouterChatService implements ChatService {
1179 final Dio _dio;
1180 final bool imageCompressionEnabled;
1181 final ImageCompressionLevel imageCompressionLevel;
1182 CancelToken? _cancelToken;
1183 Timer? _timeoutTimer;
1184
1185 OpenRouterChatService(
1186 this._dio, {
1187 this.imageCompressionEnabled = true,
1188 this.imageCompressionLevel = ImageCompressionLevel.medium,
1189 });
1190
1191 @override
1192 Stream<ChatResponse> sendMessage({
1193 required Server server,
1194 required String modelId,
1195 required List<Message> messages,
1196 required ChatParameters params,
1197 List<McpIntegration>? integrations,
1198 List<ToolDefinition>? tools,
1199 bool continueGeneration = false,
1200 }) async* {
1201 _cancelToken = CancelToken();
1202 final toolAdapter = OpenRouterToolAdapter();
1203
1204 final apiMessages = await Future.wait(
1205 messages.map(
1206 (m) => _buildOpenAiApiMessage(
1207 m,
1208 imageCompressionEnabled: imageCompressionEnabled,
1209 imageCompressionLevel: imageCompressionLevel,
1210 ),
1211 ),
1212 );
1213 _normalizeAssistantPrefill(apiMessages, continueGeneration);
1214
1215 final body = <String, dynamic>{
1216 'model': modelId,
1217 'messages': apiMessages,
1218 if (params.sendTemperature) 'temperature': params.temperature,
1219 if (params.sendTopP) 'top_p': params.topP,
1220 'max_tokens': params.maxTokens,
1221 'stream': true,
1222 };
1223 _applyOpenRouterReasoningControl(body, params);
1224
1225 if (tools != null && tools.isNotEmpty) {
1226 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
1227 if (toolsPayload.containsKey('tools')) {
1228 body['tools'] = toolsPayload['tools'];
1229 }
1230 }
1231
1232 Log.debug(
1233 'OpenRouter: Sending request to ${server.chatEndpoint} with model: $modelId',
1234 );
1235
1236 try {
1237 final response = await _dio.post<ResponseBody>(
1238 server.chatEndpoint,
1239 data: body,
1240 options: Options(
1241 responseType: ResponseType.stream,
1242 headers: {
1243 'Content-Type': 'application/json',
1244 ...buildServerAuthHeaders(server),
1245 'HTTP-Referer': 'https://localmind.app',
1246 'X-Title': 'LocalMind',
1247 },
1248 ),
1249 cancelToken: _cancelToken,
1250 );
1251
1252 final stream = response.data!.stream.cast<List<int>>().transform(
1253 utf8.decoder,
1254 );
1255 String buffer = '';
1256 DateTime? lastContentReceived;
1257 int emptyDeltaCount = 0;
1258 int logCounter = 0;
1259 bool timeoutTriggered = false;
1260
1261 // Start timeout timer
1262 _timeoutTimer = Timer(const Duration(seconds: 45), () {
1263 timeoutTriggered = true;
1264 });
1265
1266 Log.debug('OpenRouter: Starting to receive stream...');
1267
1268 try {
1269 await for (final chunk in stream) {
1270 // Check if timeout was triggered
1271 if (timeoutTriggered && lastContentReceived == null) {
1272 yield const ChatResponse(
1273 type: ChatResponseType.timeoutError,
1274 content:
1275 'Model is taking too long to respond. This may happen with free tier models.',
1276 );
1277 return;
1278 }
1279
1280 buffer += chunk;
1281 final lines = buffer.split('\n');
1282 buffer = lines.removeLast();
1283
1284 for (final line in lines) {
1285 logCounter++;
1286 if (logCounter % 10 == 0) {
1287 Log.debug('OpenRouter SSE line #$logCounter: $line');
1288 }
1289
1290 if (line.startsWith('data: ')) {
1291 final data = line.substring(6);
1292 if (data == '[DONE]') {
1293 _timeoutTimer?.cancel();
1294 Log.debug('OpenRouter: Received [DONE]');
1295 for (final call in toolAdapter.takeCompletedCalls()) {
1296 yield ChatResponse(
1297 type: ChatResponseType.toolCall,
1298 toolCall: ToolCallData(
1299 tool: call.name,
1300 arguments: call.arguments,
1301 ),
1302 );
1303 }
1304 yield const ChatResponse(type: ChatResponseType.done);
1305 return;
1306 }
1307 try {
1308 final json = jsonDecode(data) as Map<String, dynamic>;
1309 toolAdapter.consumeDynamicChunk(json);
1310
1311 // Check for errors in the response
1312 if (json['error'] != null) {
1313 _timeoutTimer?.cancel();
1314 final errorContent = _formatApiErrorContent(json['error']);
1315 Log.error('OpenRouter mid-stream error: $errorContent');
1316 yield ChatResponse(
1317 type: ChatResponseType.error,
1318 content: errorContent,
1319 );
1320 return;
1321 }
1322
1323 final choices = json['choices'] as List<dynamic>?;
1324 if (choices == null || choices.isEmpty) {
1325 continue;
1326 }
1327
1328 final firstChoice = choices[0] as Map<String, dynamic>?;
1329 if (firstChoice == null) continue;
1330
1331 final delta = firstChoice['delta'];
1332 if (delta == null || delta is! Map<String, dynamic>) continue;
1333
1334 final content = (delta['content'] ?? delta['text']) as String?;
1335 final reasoning =
1336 (delta['reasoning'] ?? delta['reasoning_content'])
1337 as String?;
1338 final refusal = delta['refusal'] as String?;
1339
1340 // Check if content is empty/null (processing but not outputting)
1341 final hasContent = content != null && content.isNotEmpty;
1342 final hasReasoning = reasoning != null && reasoning.isNotEmpty;
1343 final hasRefusal = refusal != null && refusal.isNotEmpty;
1344
1345 if (!hasContent && !hasReasoning && !hasRefusal) {
1346 emptyDeltaCount++;
1347 if (emptyDeltaCount % 10 == 0) {
1348 yield const ChatResponse(type: ChatResponseType.processing);
1349 }
1350 } else {
1351 emptyDeltaCount = 0;
1352 lastContentReceived = DateTime.now();
1353
1354 if (hasRefusal) {
1355 yield ChatResponse(
1356 type: ChatResponseType.error,
1357 content: 'Refusal: $refusal',
1358 );
1359 return;
1360 }
1361 if (hasContent) {
1362 yield ChatResponse(
1363 type: ChatResponseType.message,
1364 content: content,
1365 );
1366 }
1367 if (hasReasoning) {
1368 yield ChatResponse(
1369 type: ChatResponseType.reasoning,
1370 reasoningContent: reasoning,
1371 );
1372 }
1373 }
1374 } catch (e) {
1375 Log.error('OpenRouter parsing error: $e');
1376 }
1377 } else if (line.startsWith(': ')) {
1378 // SSE comment (keep-alive)
1379 if (logCounter % 50 == 0) {
1380 Log.debug('OpenRouter SSE comment: $line');
1381 }
1382 }
1383 }
1384 }
1385 } finally {
1386 _timeoutTimer?.cancel();
1387 }
1388
1389 // Flush any tool calls accumulated without a [DONE] marker (e.g.
1390 // connection drop or server crash before sending [DONE]).
1391 for (final call in toolAdapter.takeCompletedCalls()) {
1392 yield ChatResponse(
1393 type: ChatResponseType.toolCall,
1394 toolCall: ToolCallData(tool: call.name, arguments: call.arguments),
1395 );
1396 }
1397 } catch (e) {
1398 Log.error('OpenRouter connection error: $e');
1399 yield ChatResponse(
1400 type: ChatResponseType.error,
1401 content: _handleChatError(e),
1402 );
1403 return;
1404 } finally {
1405 _timeoutTimer?.cancel();
1406 }
1407
1408 yield const ChatResponse(type: ChatResponseType.done);
1409 Log.debug('OpenRouter: Stream ended');
1410 }
1411
1412 @override
1413 void cancelStream() {
1414 _timeoutTimer?.cancel();
1415 _cancelToken?.cancel('User cancelled');
1416 }
1417 }
1418
1419 /// Applies the standard OpenAI-compatible `reasoning_effort` field. Avoid
1420 /// broadcasting provider-specific aliases because strict compatible servers
1421 /// reject unknown request keys.
1422 void _applyOpenAICompatibleReasoningControl(
1423 Map<String, dynamic> body,
1424 ChatParameters params,
1425 ) {
1426 if (params.reasoningEnabled == false) {
1427 body['reasoning_effort'] = 'none';
1428 } else if (params.reasoningEnabled == true) {
1429 body['reasoning_effort'] = params.reasoningEffort.apiValue;
1430 }
1431 }
1432
1433 /// OpenRouter defines a `reasoning` object. Use that object alone instead of
1434 /// mixing it with the shorthand `reasoning_effort` or unrelated local-server
1435 /// flags.
1436 void _applyOpenRouterReasoningControl(
1437 Map<String, dynamic> body,
1438 ChatParameters params,
1439 ) {
1440 if (params.reasoningEnabled == false) {
1441 body['reasoning'] = {'enabled': false};
1442 } else if (params.reasoningEnabled == true) {
1443 body['reasoning'] = {'effort': params.reasoningEffort.apiValue};
1444 }
1445 }
1446
1447 /// Applies the Think toggle using only Ollama's native `/api/chat` `think`
1448 /// field (`true`/`false` or `"low"`/`"medium"`/`"high"`/`"max"`). Omits the
1449 /// key when the model doesn't support reasoning or can't run with thinking
1450 /// off, so strict servers and proxies never see unrelated `reasoning`,
1451 /// `reasoning_effort`, or `enable_thinking` keys.
1452 void _applyOllamaReasoningControl(
1453 Map<String, dynamic> body,
1454 ChatParameters params,
1455 ) {
1456 final think = resolveOllamaThinkValue(
1457 enabled: params.reasoningEnabled,
1458 effort: params.reasoningEffort,
1459 allowedOptions: params.reasoningAllowedOptions,
1460 defaultOption: params.reasoningDefaultOption,
1461 );
1462 if (think != null) body['think'] = think;
1463 }
1464
1465 String _formatApiErrorContent(dynamic error) {
1466 if (error is Map) {
1467 final map = Map<String, dynamic>.from(error);
1468 final parsed = ChatApiError.fromErrorMap(map);
1469 if (parsed != null) {
1470 // Use the unified formatter so the bubble benefits from humanising
1471 // and structured metadata even when the body is already shaped like
1472 // an OpenAI error.
1473 try {
1474 return ChatErrorFormatter.formatBody(jsonEncode(map));
1475 } catch (_) {
1476 return parsed.encode();
1477 }
1478 }
1479 }
1480 return ChatApiError(message: error.toString()).encode();
1481 }
1482
1483 String _handleChatError(dynamic e) {
1484 if (e is DioException) {
1485 if (e.response != null) {
1486 final status = e.response!.statusCode;
1487 final data = e.response!.data;
1488 if (data is String && data.trim().isNotEmpty) {
1489 return ChatErrorFormatter.formatBody(data, statusCode: status);
1490 }
1491 final parsed = ChatApiError.fromResponseBody(data);
1492 if (parsed != null) {
1493 return parsed.copyWithCodeIfMissing(status?.toString()).encode();
1494 }
1495 // Let the unified formatter map known HTTP status codes.
1496 return ChatErrorFormatter.formatBody('', statusCode: status);
1497 }
1498
1499 switch (e.type) {
1500 case DioExceptionType.connectionTimeout:
1501 case DioExceptionType.sendTimeout:
1502 case DioExceptionType.receiveTimeout:
1503 return const ChatApiError(
1504 message: 'The model took too long to respond.',
1505 type: 'timeout',
1506 ).encode();
1507 case DioExceptionType.connectionError:
1508 return const ChatApiError(
1509 message:
1510 'Could not reach the AI server. Confirm it is running and the address in Settings is correct.',
1511 type: 'connection_error',
1512 ).encode();
1513 case DioExceptionType.cancel:
1514 return const ChatApiError(
1515 message: 'Request cancelled.',
1516 type: 'cancelled',
1517 ).encode();
1518 default:
1519 return ChatApiError(
1520 message: e.message ?? 'Connection error.',
1521 type: e.type.name,
1522 ).encode();
1523 }
1524 }
1525 return ChatApiError(message: e.toString()).encode();
1526 }
1527
1528 String _roleToString(MessageRole role) {
1529 switch (role) {
1530 case MessageRole.user:
1531 return 'user';
1532 case MessageRole.assistant:
1533 return 'assistant';
1534 case MessageRole.system:
1535 return 'system';
1536 case MessageRole.tool:
1537 return 'tool';
1538 }
1539 }
1540
1541 /// Normalises the trailing assistant message in a chat `messages` array.
1542 /// When NOT continuing generation, drops trailing empty assistant prefill
1543 /// messages. Besides triggering "prefill incompatible with enable_thinking"
1544 /// errors on OpenAI-style thinking models, an empty trailing assistant turn
1545 /// makes Ollama's Qwen 3.5 parser misclassify the generated continuation as
1546 /// `message.thinking`. When continuing generation, keeps the trailing
1547 /// assistant prefill but ensures its `content` is a plain string (image
1548 /// content-part arrays are only valid on user messages, and
1549 /// [_buildOpenAiApiMessage] never produces them for assistants anyway).
1550 void _normalizeAssistantPrefill(
1551 List<Map<String, dynamic>> apiMessages,
1552 bool continueGeneration,
1553 ) {
1554 if (!continueGeneration) {
1555 while (apiMessages.isNotEmpty &&
1556 apiMessages.last['role'] == 'assistant' &&
1557 (apiMessages.last['content'] == null ||
1558 (apiMessages.last['content'] is String &&
1559 (apiMessages.last['content'] as String).isEmpty))) {
1560 apiMessages.removeLast();
1561 }
1562 } else if (apiMessages.isNotEmpty &&
1563 apiMessages.last['role'] == 'assistant') {
1564 final last = apiMessages.last;
1565 last['content'] = (last['content'] as String?) ?? '';
1566 }
1567 }
1568
1569 /// Builds the OpenAI-style request payload for a single [Message], including
1570 /// attachments. Supported documents are read and inlined into the text content
1571 /// (mirroring the Ollama path), and image attachments are emitted as OpenAI
1572 /// multimodal `image_url` content parts carrying base64 data URLs.
1573 ///
1574 /// Image parts are only emitted for user messages — OpenAI rejects content
1575 /// part arrays on other roles — so non-user messages keep a plain string
1576 /// `content` (which still receives inlined text attachments).
1577 Future<Map<String, dynamic>> _buildOpenAiApiMessage(
1578 Message m, {
1579 required bool imageCompressionEnabled,
1580 required ImageCompressionLevel imageCompressionLevel,
1581 }) async {
1582 final map = <String, dynamic>{
1583 'role': _roleToString(m.role),
1584 'content': m.content,
1585 };
1586
1587 var textContent = m.content;
1588 final imageParts = <Map<String, dynamic>>[];
1589
1590 for (final path in m.attachmentPaths ?? const <String>[]) {
1591 if (AttachmentHelpers.isDocumentPath(path)) {
1592 final text = await AttachmentHelpers.readDocumentFile(path);
1593 if (text != null) {
1594 textContent = AttachmentHelpers.appendTextAttachment(
1595 textContent,
1596 AttachmentHelpers.fileNameOf(path),
1597 text,
1598 );
1599 }
1600 continue;
1601 }
1602
1603 if (!AttachmentHelpers.isImagePath(path)) continue;
1604
1605 try {
1606 final file = File(path);
1607 if (!await file.exists()) continue;
1608
1609 final fileBytes = await ImageUploadUtils.prepareImageBytes(
1610 file,
1611 enabled: imageCompressionEnabled,
1612 level: imageCompressionLevel,
1613 );
1614 final base64Image = await Isolate.run(() {
1615 try {
1616 return base64Encode(fileBytes);
1617 } catch (_) {
1618 return null;
1619 }
1620 });
1621
1622 if (base64Image != null) {
1623 // Compression re-encodes resized images to PNG; detect that so the
1624 // data URL advertises the bytes actually being sent.
1625 final mimeType = _looksLikePng(fileBytes)
1626 ? 'image/png'
1627 : AttachmentHelpers.mimeTypeForImage(path);
1628 imageParts.add({
1629 'type': 'image_url',
1630 'image_url': {'url': 'data:$mimeType;base64,$base64Image'},
1631 });
1632 }
1633 } catch (e) {
1634 Log.error('Failed to read attachment for OpenAI format: $e');
1635 }
1636 }
1637
1638 if (imageParts.isNotEmpty && m.role == MessageRole.user) {
1639 map['content'] = [
1640 if (textContent.isNotEmpty) {'type': 'text', 'text': textContent},
1641 ...imageParts,
1642 ];
1643 } else if (textContent != m.content) {
1644 map['content'] = textContent;
1645 }
1646
1647 if (m.role == MessageRole.tool && m.toolCallId != null) {
1648 map['tool_call_id'] = m.toolCallId;
1649 }
1650 if (m.role == MessageRole.assistant &&
1651 m.toolCalls != null &&
1652 m.toolCalls!.isNotEmpty) {
1653 map['tool_calls'] = m.toolCalls!.map((tc) {
1654 return {
1655 'id': tc.id,
1656 'type': 'function',
1657 'function': {
1658 'name': tc.toolName,
1659 'arguments': jsonEncode(tc.arguments),
1660 },
1661 };
1662 }).toList();
1663 }
1664 return map;
1665 }
1666
1667 bool _looksLikePng(Uint8List bytes) {
1668 return bytes.length >= 4 &&
1669 bytes[0] == 0x89 &&
1670 bytes[1] == 0x50 &&
1671 bytes[2] == 0x4e &&
1672 bytes[3] == 0x47;
1673 }
1674
1675 /// Returns the streaming-parsing adapter for a server type.
1676 ///
1677 /// OpenAI-compatible and Ollama servers use adapter-based tool call parsing
1678 /// from streaming chunks. LM Studio and on-device servers collect tool calls
1679 /// directly from [ChatResponseType.toolCall] events emitted during streaming
1680 /// and feed them to the loop via [ToolExecutionLoop.run]'s [preParsedCalls].
1681 ToolTransportAdapter createAdapterForServerType(ServerType type) =>
1682 switch (type) {
1683 ServerType.openAICompatible => OpenAiToolAdapter(),
1684 ServerType.openRouter => OpenRouterToolAdapter(),
1685 ServerType.requesty => OpenAiToolAdapter(),
1686 ServerType.lmStudio => OpenAiToolAdapter(),
1687 ServerType.onDevice => OpenAiToolAdapter(),
1688 ServerType.ollama => OllamaToolAdapter(),
1689 ServerType.ollamaCloud => OllamaToolAdapter(),
1690 };