23 files changed
+1115
-45
ios/Runner.xcodeproj/project.pbxproj
+3
@@ -510,6 +510,7 @@
510
DEVELOPMENT_TEAM = TZX5LG7ZW5;
511
ENABLE_BITCODE = NO;
512
INFOPLIST_FILE = Runner/Info.plist;
513
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
514
LD_RUNPATH_SEARCH_PATHS = (
515
"$(inherited)",
516
"@executable_path/Frameworks",
@@ -694,6 +695,7 @@
695
DEVELOPMENT_TEAM = TZX5LG7ZW5;
696
ENABLE_BITCODE = NO;
697
INFOPLIST_FILE = Runner/Info.plist;
698
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
699
LD_RUNPATH_SEARCH_PATHS = (
700
"$(inherited)",
701
"@executable_path/Frameworks",
@@ -718,6 +720,7 @@
720
DEVELOPMENT_TEAM = TZX5LG7ZW5;
721
ENABLE_BITCODE = NO;
722
INFOPLIST_FILE = Runner/Info.plist;
723
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
724
LD_RUNPATH_SEARCH_PATHS = (
725
"$(inherited)",
726
"@executable_path/Frameworks",
lib/features/chat/data/chat_service.dart
+167
-33
@@ -2,6 +2,7 @@ import 'dart:async';
2
import 'dart:convert';
3
import 'dart:io';
4
import 'dart:isolate';
5
+import 'dart:typed_data';
6
import 'package:dio/dio.dart';
7
import '../../servers/data/models/server.dart';
8
import 'models/message.dart';
@@ -50,7 +51,11 @@ abstract class ChatService {
51
imageCompressionLevel: imageCompressionLevel,
52
);
53
case ServerType.openAICompatible:
53
- return OpenAICompatibleChatService(dio);
54
+ return OpenAICompatibleChatService(
55
+ dio,
56
+ imageCompressionEnabled: imageCompressionEnabled,
57
+ imageCompressionLevel: imageCompressionLevel,
58
+ );
59
case ServerType.ollama:
60
case ServerType.ollamaCloud:
61
return OllamaChatService(
@@ -59,7 +64,11 @@ abstract class ChatService {
64
imageCompressionLevel: imageCompressionLevel,
65
);
66
case ServerType.openRouter:
62
- return OpenRouterChatService(dio);
67
+ return OpenRouterChatService(
68
+ dio,
69
+ imageCompressionEnabled: imageCompressionEnabled,
70
+ imageCompressionLevel: imageCompressionLevel,
71
+ );
72
case ServerType.onDevice:
73
if (onDeviceGemma == null) {
74
throw StateError(
@@ -508,12 +517,11 @@ class LMStudioChatService implements ChatService {
517
}
518
}
519
for (final m in visible) {
511
- final imageItems = await _imageItemsFor(m);
512
- final textContent = _messageTextWithAttachments(m);
520
+ final textContent = await _messageTextWithAttachments(m);
521
if (textContent.isNotEmpty) {
522
items.add({'type': 'text', 'content': textContent});
523
}
516
- items.addAll(imageItems);
524
+ items.addAll(await _imageItemsFor(m));
525
}
526
return items;
527
}
@@ -551,22 +559,22 @@ class LMStudioChatService implements ChatService {
559
return items;
560
}
561
554
- String _messageTextWithAttachments(Message m) {
562
+ /// Builds the text portion of a typed input item, inlining any `.txt`/`.md`
563
+ /// attachment contents into the message text so the model actually receives
564
+ /// them. Image attachments are handled separately by [_imageItemsFor].
565
+ Future<String> _messageTextWithAttachments(Message m) async {
566
var textContent = m.content;
567
final paths = m.attachmentPaths ?? const <String>[];
568
for (final path in paths) {
569
if (!AttachmentHelpers.isTextPath(path)) continue;
559
- // Note: we don't read the file contents here synchronously — the
560
- // native endpoint's typed input doesn't carry file metadata. The
561
- // caller is expected to have already inlined text attachments into
562
- // `m.content` upstream via AttachmentHelpers. If not, the image path
563
- // above still handles images.
570
+ final text = await AttachmentHelpers.readTextFile(path);
571
+ if (text == null) continue;
572
final fileName = AttachmentHelpers.fileNameOf(path);
573
if (fileName.isEmpty) continue;
574
textContent = AttachmentHelpers.appendTextAttachment(
575
textContent,
576
fileName,
569
- '<inline text attachment: $fileName>',
577
+ text,
578
);
579
}
580
return textContent;
@@ -612,10 +620,16 @@ class LMStudioChatService implements ChatService {
620
621
class OpenAICompatibleChatService implements ChatService {
622
final Dio _dio;
623
+ final bool imageCompressionEnabled;
624
+ final ImageCompressionLevel imageCompressionLevel;
625
CancelToken? _cancelToken;
626
Timer? _timeoutTimer;
627
618
- OpenAICompatibleChatService(this._dio);
628
+ OpenAICompatibleChatService(
629
+ this._dio, {
630
+ this.imageCompressionEnabled = true,
631
+ this.imageCompressionLevel = ImageCompressionLevel.medium,
632
+ });
633
634
@override
635
Stream<ChatResponse> sendMessage({
@@ -630,23 +644,19 @@ class OpenAICompatibleChatService implements ChatService {
644
_cancelToken = CancelToken();
645
final toolAdapter = OpenAiToolAdapter();
646
633
- final apiMessages = messages.map(_messageToApiMap).toList();
634
- // Strip trailing empty assistant messages to avoid "prefill incompatible
635
- // with enable_thinking" errors from servers running thinking models
636
- if (!continueGeneration) {
637
- while (apiMessages.isNotEmpty &&
638
- apiMessages.last['role'] == 'assistant' &&
639
- (apiMessages.last['content'] == null ||
640
- (apiMessages.last['content'] is String &&
641
- (apiMessages.last['content'] as String).isEmpty))) {
642
- apiMessages.removeLast();
643
- }
644
- } else if (apiMessages.isNotEmpty &&
645
- apiMessages.last['role'] == 'assistant') {
646
- // Keep the assistant prefill for continuation; ensure content is a string.
647
- final last = apiMessages.last;
648
- last['content'] = (last['content'] as String?) ?? '';
649
- }
647
+ final apiMessages = await Future.wait(
648
+ messages.map(
649
+ (m) => _buildOpenAiApiMessage(
650
+ m,
651
+ imageCompressionEnabled: imageCompressionEnabled,
652
+ imageCompressionLevel: imageCompressionLevel,
653
+ ),
654
+ ),
655
+ );
656
+ // Strip trailing empty assistant prefill (or normalise it when
657
+ // continuing generation) to avoid "prefill incompatible with
658
+ // enable_thinking" errors from servers running thinking models.
659
+ _normalizeAssistantPrefill(apiMessages, continueGeneration);
660
661
final body = <String, dynamic>{
662
'model': modelId,
@@ -1130,10 +1140,16 @@ class OllamaChatService implements ChatService {
1140
1141
class OpenRouterChatService implements ChatService {
1142
final Dio _dio;
1143
+ final bool imageCompressionEnabled;
1144
+ final ImageCompressionLevel imageCompressionLevel;
1145
CancelToken? _cancelToken;
1146
Timer? _timeoutTimer;
1147
1136
- OpenRouterChatService(this._dio);
1148
+ OpenRouterChatService(
1149
+ this._dio, {
1150
+ this.imageCompressionEnabled = true,
1151
+ this.imageCompressionLevel = ImageCompressionLevel.medium,
1152
+ });
1153
1154
@override
1155
Stream<ChatResponse> sendMessage({
@@ -1148,9 +1164,20 @@ class OpenRouterChatService implements ChatService {
1164
_cancelToken = CancelToken();
1165
final toolAdapter = OpenRouterToolAdapter();
1166
1167
+ final apiMessages = await Future.wait(
1168
+ messages.map(
1169
+ (m) => _buildOpenAiApiMessage(
1170
+ m,
1171
+ imageCompressionEnabled: imageCompressionEnabled,
1172
+ imageCompressionLevel: imageCompressionLevel,
1173
+ ),
1174
+ ),
1175
+ );
1176
+ _normalizeAssistantPrefill(apiMessages, continueGeneration);
1177
+
1178
final body = <String, dynamic>{
1179
'model': modelId,
1153
- 'messages': messages.map(_messageToApiMap).toList(),
1180
+ 'messages': apiMessages,
1181
'temperature': params.temperature,
1182
'top_p': params.topP,
1183
'max_tokens': params.maxTokens,
@@ -1454,11 +1481,110 @@ String _roleToString(MessageRole role) {
1481
}
1482
}
1483
1457
-Map<String, dynamic> _messageToApiMap(Message m) {
1484
+/// Normalises the trailing assistant message in an OpenAI-style `messages`
1485
+/// array. When NOT continuing generation, drops trailing empty assistant
1486
+/// prefill messages (they trigger "prefill incompatible with
1487
+/// enable_thinking" errors on thinking models). When continuing generation,
1488
+/// keeps the trailing assistant prefill but ensures its `content` is a
1489
+/// plain string (image content-part arrays are only valid on user messages,
1490
+/// and [_buildOpenAiApiMessage] never produces them for assistants anyway).
1491
+void _normalizeAssistantPrefill(
1492
+ List<Map<String, dynamic>> apiMessages,
1493
+ bool continueGeneration,
1494
+) {
1495
+ if (!continueGeneration) {
1496
+ while (apiMessages.isNotEmpty &&
1497
+ apiMessages.last['role'] == 'assistant' &&
1498
+ (apiMessages.last['content'] == null ||
1499
+ (apiMessages.last['content'] is String &&
1500
+ (apiMessages.last['content'] as String).isEmpty))) {
1501
+ apiMessages.removeLast();
1502
+ }
1503
+ } else if (apiMessages.isNotEmpty &&
1504
+ apiMessages.last['role'] == 'assistant') {
1505
+ final last = apiMessages.last;
1506
+ last['content'] = (last['content'] as String?) ?? '';
1507
+ }
1508
+}
1509
+
1510
+/// Builds the OpenAI-style request payload for a single [Message], including
1511
+/// attachments. `.txt`/`.md` files are read and inlined into the text content
1512
+/// (mirroring the Ollama path), and image attachments are emitted as OpenAI
1513
+/// multimodal `image_url` content parts carrying base64 data URLs.
1514
+///
1515
+/// Image parts are only emitted for user messages — OpenAI rejects content
1516
+/// part arrays on other roles — so non-user messages keep a plain string
1517
+/// `content` (which still receives inlined text attachments).
1518
+Future<Map<String, dynamic>> _buildOpenAiApiMessage(
1519
+ Message m, {
1520
+ required bool imageCompressionEnabled,
1521
+ required ImageCompressionLevel imageCompressionLevel,
1522
+}) async {
1523
final map = <String, dynamic>{
1524
'role': _roleToString(m.role),
1525
'content': m.content,
1526
};
1527
+
1528
+ var textContent = m.content;
1529
+ final imageParts = <Map<String, dynamic>>[];
1530
+
1531
+ for (final path in m.attachmentPaths ?? const <String>[]) {
1532
+ if (AttachmentHelpers.isTextPath(path)) {
1533
+ final text = await AttachmentHelpers.readTextFile(path);
1534
+ if (text != null) {
1535
+ textContent = AttachmentHelpers.appendTextAttachment(
1536
+ textContent,
1537
+ AttachmentHelpers.fileNameOf(path),
1538
+ text,
1539
+ );
1540
+ }
1541
+ continue;
1542
+ }
1543
+
1544
+ if (!AttachmentHelpers.isImagePath(path)) continue;
1545
+
1546
+ try {
1547
+ final file = File(path);
1548
+ if (!await file.exists()) continue;
1549
+
1550
+ final fileBytes = await ImageUploadUtils.prepareImageBytes(
1551
+ file,
1552
+ enabled: imageCompressionEnabled,
1553
+ level: imageCompressionLevel,
1554
+ );
1555
+ final base64Image = await Isolate.run(() {
1556
+ try {
1557
+ return base64Encode(fileBytes);
1558
+ } catch (_) {
1559
+ return null;
1560
+ }
1561
+ });
1562
+
1563
+ if (base64Image != null) {
1564
+ // Compression re-encodes resized images to PNG; detect that so the
1565
+ // data URL advertises the bytes actually being sent.
1566
+ final mimeType = _looksLikePng(fileBytes)
1567
+ ? 'image/png'
1568
+ : AttachmentHelpers.mimeTypeForImage(path);
1569
+ imageParts.add({
1570
+ 'type': 'image_url',
1571
+ 'image_url': {'url': 'data:$mimeType;base64,$base64Image'},
1572
+ });
1573
+ }
1574
+ } catch (e) {
1575
+ Log.error('Failed to read attachment for OpenAI format: $e');
1576
+ }
1577
+ }
1578
+
1579
+ if (imageParts.isNotEmpty && m.role == MessageRole.user) {
1580
+ map['content'] = [
1581
+ if (textContent.isNotEmpty) {'type': 'text', 'text': textContent},
1582
+ ...imageParts,
1583
+ ];
1584
+ } else if (textContent != m.content) {
1585
+ map['content'] = textContent;
1586
+ }
1587
+
1588
if (m.role == MessageRole.tool && m.toolCallId != null) {
1589
map['tool_call_id'] = m.toolCallId;
1590
}
@@ -1479,6 +1605,14 @@ Map<String, dynamic> _messageToApiMap(Message m) {
1605
return map;
1606
}
1607
1608
+bool _looksLikePng(Uint8List bytes) {
1609
+ return bytes.length >= 4 &&
1610
+ bytes[0] == 0x89 &&
1611
+ bytes[1] == 0x50 &&
1612
+ bytes[2] == 0x4e &&
1613
+ bytes[3] == 0x47;
1614
+}
1615
+
1616
/// Returns the streaming-parsing adapter for a server type.
1617
///
1618
/// OpenAI-compatible and Ollama servers use adapter-based tool call parsing
lib/features/chat/views/components/model_info_sheet.dart
+21
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
3
import 'package:flutter/services.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
import 'package:shadcn_ui/shadcn_ui.dart';
6
+import 'package:localmind/core/models/enums.dart';
7
import 'package:localmind/features/chat/providers/chat_params_providers.dart';
8
import 'package:localmind/features/models/data/models/model_info.dart';
9
import 'package:localmind/features/servers/providers/server_providers.dart';
@@ -32,6 +33,12 @@ Future<void> showModelInfoSheet(
33
final contextLength = ref.read(chatParamsProvider).contextLength;
34
final identifier = model?.id ?? modelId;
35
36
+ final capabilities = <String>[
37
+ if (model?.supportsVision == true) l10n.lm_studio_vision,
38
+ if (model?.supportsReasoning == true) l10n.lm_studio_reasoning,
39
+ if (model?.supportsToolUse == true) l10n.lm_studio_tool_use,
40
+ ];
41
+
42
await showShadSheet(
43
context: context,
44
builder: (ctx) => ShadSheet(
@@ -47,10 +54,24 @@ Future<void> showModelInfoSheet(
54
label: l10n.model_identifier,
55
value: identifier,
56
),
57
+ if (capabilities.isNotEmpty)
58
+ _ModelInfoRow(
59
+ label: l10n.model_capabilities,
60
+ value: capabilities.join(', '),
61
+ ),
62
_ModelInfoRow(
63
label: l10n.context_length,
64
value: contextLength.toString(),
65
),
66
+ if (model?.serverType == ServerType.openRouter &&
67
+ model?.pricingLabel != null)
68
+ _ModelInfoRow(
69
+ label: l10n.model_api_pricing,
70
+ value: model!.isPricingFree
71
+ ? l10n.openrouter_pricing_free
72
+ : '${model.formattedInputPrice ?? '—'} / '
73
+ '${model.formattedOutputPrice ?? '—'}',
74
+ ),
75
],
76
),
77
),
lib/features/models/components/model_tile.dart
+16
@@ -162,6 +162,22 @@ class ModelTile extends StatelessWidget {
162
),
163
isDark: isDark,
164
),
165
+ if (model.serverType == ServerType.openRouter &&
166
+ model.pricingLabel != null)
167
+ Tooltip(
168
+ message: model.isPricingFree
169
+ ? l10n.openrouter_pricing_free
170
+ : l10n.openrouter_pricing_tooltip(
171
+ model.formattedInputPrice ?? '',
172
+ model.formattedOutputPrice ?? '',
173
+ ),
174
+ child: MetadataChip(
175
+ label: model.isPricingFree
176
+ ? l10n.openrouter_pricing_free
177
+ : model.pricingLabel!,
178
+ isDark: isDark,
179
+ ),
180
+ ),
181
],
182
),
183
if (model.supportsReasoning)
lib/features/models/data/models/model_info.dart
+55
@@ -20,6 +20,10 @@ class ModelInfo {
20
final bool supportsVision;
21
final bool supportsReasoning;
22
final bool supportsToolUse;
23
+ /// Input token price in USD per 1M tokens (OpenRouter `pricing.prompt`).
24
+ final double? inputPricePerMillion;
25
+ /// Output token price in USD per 1M tokens (OpenRouter `pricing.completion`).
26
+ final double? outputPricePerMillion;
27
28
ModelInfo({
29
required this.id,
@@ -40,6 +44,8 @@ class ModelInfo {
44
this.supportsVision = false,
45
this.supportsReasoning = false,
46
this.supportsToolUse = false,
47
+ this.inputPricePerMillion,
48
+ this.outputPricePerMillion,
49
});
50
51
String get displayName {
@@ -73,6 +79,49 @@ class ModelInfo {
79
return '${parameterCount!.toStringAsFixed(2)}B';
80
}
81
82
+ /// True when both prices are known and are zero (a genuinely free model).
83
+ bool get isPricingFree =>
84
+ inputPricePerMillion != null &&
85
+ outputPricePerMillion != null &&
86
+ inputPricePerMillion == 0 &&
87
+ outputPricePerMillion == 0;
88
+
89
+ /// Compact chip label for API pricing, e.g. `$2.50/$10.00`. Returns
90
+ /// `Free` for zero-cost models and null when no pricing is known.
91
+ String? get pricingLabel {
92
+ if (inputPricePerMillion == null && outputPricePerMillion == null) {
93
+ return null;
94
+ }
95
+ if (isPricingFree) return 'Free';
96
+ return [
97
+ if (inputPricePerMillion != null)
98
+ '\$${_formatPriceValue(inputPricePerMillion!)}',
99
+ if (outputPricePerMillion != null)
100
+ '\$${_formatPriceValue(outputPricePerMillion!)}',
101
+ ].join('/');
102
+ }
103
+
104
+ /// Formatted input price (e.g. `$2.50`) or null when unknown.
105
+ String? get formattedInputPrice => inputPricePerMillion == null
106
+ ? null
107
+ : '\$${_formatPriceValue(inputPricePerMillion!)}';
108
+
109
+ /// Formatted output price (e.g. `$10.00`) or null when unknown.
110
+ String? get formattedOutputPrice => outputPricePerMillion == null
111
+ ? null
112
+ : '\$${_formatPriceValue(outputPricePerMillion!)}';
113
+
114
+ static String _formatPriceValue(double value) {
115
+ if (value >= 100) return value.round().toString();
116
+ if (value >= 0.01) return value.toStringAsFixed(2);
117
+ var s = value.toStringAsFixed(6);
118
+ while (s.endsWith('0')) {
119
+ s = s.substring(0, s.length - 1);
120
+ }
121
+ if (s.endsWith('.')) s = s.substring(0, s.length - 1);
122
+ return s;
123
+ }
124
+
125
ModelInfo copyWith({
126
String? id,
127
String? name,
@@ -92,6 +141,8 @@ class ModelInfo {
141
bool? supportsVision,
142
bool? supportsReasoning,
143
bool? supportsToolUse,
144
+ double? inputPricePerMillion,
145
+ double? outputPricePerMillion,
146
}) {
147
return ModelInfo(
148
id: id ?? this.id,
@@ -112,6 +163,10 @@ class ModelInfo {
163
supportsVision: supportsVision ?? this.supportsVision,
164
supportsReasoning: supportsReasoning ?? this.supportsReasoning,
165
supportsToolUse: supportsToolUse ?? this.supportsToolUse,
166
+ inputPricePerMillion:
167
+ inputPricePerMillion ?? this.inputPricePerMillion,
168
+ outputPricePerMillion:
169
+ outputPricePerMillion ?? this.outputPricePerMillion,
170
);
171
}
172
}
lib/features/servers/data/server_api_service.dart
+62
@@ -553,6 +553,8 @@ class ServerApiService {
553
!modality.contains('->') ||
554
modality.split('->').last.contains('text');
555
if (isTextModel) {
556
+ final capabilities = _parseOpenRouterCapabilities(item);
557
+ final pricing = _parseOpenRouterPricing(item['pricing']);
558
models.add(
559
ModelInfo(
560
id: item['id']?.toString() ?? '',
@@ -562,6 +564,11 @@ class ServerApiService {
564
architecture: item['architecture']?['tokenizer'] as String?,
565
serverType: server.type,
566
serverId: server.id,
567
+ supportsVision: capabilities.supportsVision,
568
+ supportsReasoning: capabilities.supportsReasoning,
569
+ supportsToolUse: capabilities.supportsToolUse,
570
+ inputPricePerMillion: pricing.inputPricePerMillion,
571
+ outputPricePerMillion: pricing.outputPricePerMillion,
572
),
573
);
574
}
@@ -569,6 +576,61 @@ class ServerApiService {
576
return models;
577
}
578
579
+ ({
580
+ bool supportsVision,
581
+ bool supportsReasoning,
582
+ bool supportsToolUse,
583
+ }) _parseOpenRouterCapabilities(Map<dynamic, dynamic> item) {
584
+ final architecture = item['architecture'];
585
+ final arch = architecture is Map ? architecture : const <String, dynamic>{};
586
+ final inputModalities = (arch['input_modalities'] as List?)
587
+ ?.map((e) => e.toString().trim().toLowerCase())
588
+ .toSet() ??
589
+ <String>{};
590
+ final modality = arch['modality']?.toString().toLowerCase() ?? '';
591
+ final supportsVision =
592
+ inputModalities.contains('image') || modality.contains('image');
593
+
594
+ final supportedParams = (item['supported_parameters'] as List?)
595
+ ?.map((e) => e.toString().trim().toLowerCase())
596
+ .toSet() ??
597
+ <String>{};
598
+ final supportsToolUse = supportedParams.contains('tools') ||
599
+ supportedParams.contains('tool_choice');
600
+ final reasoningField = item['reasoning'];
601
+ final supportsReasoning =
602
+ (reasoningField is Map && reasoningField.isNotEmpty) ||
603
+ supportedParams.contains('reasoning') ||
604
+ supportedParams.contains('include_reasoning');
605
+
606
+ return (
607
+ supportsVision: supportsVision,
608
+ supportsReasoning: supportsReasoning,
609
+ supportsToolUse: supportsToolUse,
610
+ );
611
+ }
612
+
613
+ /// Parses OpenRouter `pricing` into USD-per-1M-token prices. OpenRouter
614
+ /// quotes prices per token (e.g. `0.0000025`), so the value is scaled by
615
+ /// 1,000,000 for display-friendly numbers.
616
+ ({double? inputPricePerMillion, double? outputPricePerMillion})
617
+ _parseOpenRouterPricing(dynamic raw) {
618
+ if (raw is! Map) {
619
+ return (inputPricePerMillion: null, outputPricePerMillion: null);
620
+ }
621
+ return (
622
+ inputPricePerMillion: _openRouterPricePerMillion(raw['prompt']),
623
+ outputPricePerMillion: _openRouterPricePerMillion(raw['completion']),
624
+ );
625
+ }
626
+
627
+ double? _openRouterPricePerMillion(dynamic value) {
628
+ if (value == null) return null;
629
+ final parsed = double.tryParse(value.toString());
630
+ if (parsed == null || !parsed.isFinite || parsed < 0) return null;
631
+ return parsed * 1000000;
632
+ }
633
+
634
String _formatModelName(String id) {
635
return id
636
.replaceAll('-', ' ')
lib/l10n/app_en.arb
+20
@@ -2960,6 +2960,8 @@
2960
"model_info": "Model info",
2961
"model_name": "Model name",
2962
"model_identifier": "Identifier",
2963
+ "model_capabilities": "Capabilities",
2964
+ "model_api_pricing": "API pricing (per 1M tokens)",
2965
"not_available": "Not available",
2966
"save_message_folders": "Save message",
2967
"remove_from_saved": "Remove from saved",
@@ -3040,6 +3042,24 @@
3042
"lm_studio_vision": "Vision",
3043
"lm_studio_tool_use": "Tool use",
3044
"lm_studio_reasoning": "Reasoning",
3045
+ "openrouter_pricing_free": "Free",
3046
+ "@openrouter_pricing_free": {
3047
+ "description": "Chip label shown for free OpenRouter models"
3048
+ },
3049
+ "openrouter_pricing_tooltip": "Input {input} / Output {output} per 1M tokens",
3050
+ "@openrouter_pricing_tooltip": {
3051
+ "description": "Tooltip showing OpenRouter API pricing per 1M tokens",
3052
+ "placeholders": {
3053
+ "input": {
3054
+ "type": "String",
3055
+ "example": "$2.50"
3056
+ },
3057
+ "output": {
3058
+ "type": "String",
3059
+ "example": "$10.00"
3060
+ }
3061
+ }
3062
+ },
3063
"lm_studio_download_options": "Download options",
3064
"lm_studio_download": "Download",
3065
"lm_studio_download_size": "Download {size}",
lib/l10n/app_localizations.dart
+24
@@ -4713,6 +4713,18 @@ abstract class AppLocalizations {
4713
/// **'Identifier'**
4714
String get model_identifier;
4715
4716
+ /// No description provided for @model_capabilities.
4717
+ ///
4718
+ /// In en, this message translates to:
4719
+ /// **'Capabilities'**
4720
+ String get model_capabilities;
4721
+
4722
+ /// No description provided for @model_api_pricing.
4723
+ ///
4724
+ /// In en, this message translates to:
4725
+ /// **'API pricing (per 1M tokens)'**
4726
+ String get model_api_pricing;
4727
+
4728
/// No description provided for @not_available.
4729
///
4730
/// In en, this message translates to:
@@ -5079,6 +5091,18 @@ abstract class AppLocalizations {
5091
/// **'Reasoning'**
5092
String get lm_studio_reasoning;
5093
5094
+ /// Chip label shown for free OpenRouter models
5095
+ ///
5096
+ /// In en, this message translates to:
5097
+ /// **'Free'**
5098
+ String get openrouter_pricing_free;
5099
+
5100
+ /// Tooltip showing OpenRouter API pricing per 1M tokens
5101
+ ///
5102
+ /// In en, this message translates to:
5103
+ /// **'Input {input} / Output {output} per 1M tokens'**
5104
+ String openrouter_pricing_tooltip(String input, String output);
5105
+
5106
/// No description provided for @lm_studio_download_options.
5107
///
5108
/// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+14
@@ -2506,6 +2506,12 @@ class AppLocalizationsAr extends AppLocalizations {
2506
@override
2507
String get model_identifier => 'معرف النموذج';
2508
2509
+ @override
2510
+ String get model_capabilities => 'Capabilities';
2511
+
2512
+ @override
2513
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2514
+
2515
@override
2516
String get not_available => 'غير متاح';
2517
@@ -2698,6 +2704,14 @@ class AppLocalizationsAr extends AppLocalizations {
2704
@override
2705
String get lm_studio_reasoning => 'التفكير والاستنتاج';
2706
2707
+ @override
2708
+ String get openrouter_pricing_free => 'Free';
2709
+
2710
+ @override
2711
+ String openrouter_pricing_tooltip(String input, String output) {
2712
+ return 'Input $input / Output $output per 1M tokens';
2713
+ }
2714
+
2715
@override
2716
String get lm_studio_download_options => 'خيارات التنزيل';
2717
lib/l10n/app_localizations_bn.dart
+14
@@ -2527,6 +2527,12 @@ class AppLocalizationsBn extends AppLocalizations {
2527
@override
2528
String get model_identifier => 'মডেল সনাক্তকারী';
2529
2530
+ @override
2531
+ String get model_capabilities => 'Capabilities';
2532
+
2533
+ @override
2534
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2535
+
2536
@override
2537
String get not_available => 'অনুপলব্ধ';
2538
@@ -2721,6 +2727,14 @@ class AppLocalizationsBn extends AppLocalizations {
2727
@override
2728
String get lm_studio_reasoning => 'রিজনিং';
2729
2730
+ @override
2731
+ String get openrouter_pricing_free => 'Free';
2732
+
2733
+ @override
2734
+ String openrouter_pricing_tooltip(String input, String output) {
2735
+ return 'Input $input / Output $output per 1M tokens';
2736
+ }
2737
+
2738
@override
2739
String get lm_studio_download_options => 'ডাউনলোড বিকল্প';
2740
lib/l10n/app_localizations_en.dart
+14
@@ -2509,6 +2509,12 @@ class AppLocalizationsEn extends AppLocalizations {
2509
@override
2510
String get model_identifier => 'Identifier';
2511
2512
+ @override
2513
+ String get model_capabilities => 'Capabilities';
2514
+
2515
+ @override
2516
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2517
+
2518
@override
2519
String get not_available => 'Not available';
2520
@@ -2709,6 +2715,14 @@ class AppLocalizationsEn extends AppLocalizations {
2715
@override
2716
String get lm_studio_reasoning => 'Reasoning';
2717
2718
+ @override
2719
+ String get openrouter_pricing_free => 'Free';
2720
+
2721
+ @override
2722
+ String openrouter_pricing_tooltip(String input, String output) {
2723
+ return 'Input $input / Output $output per 1M tokens';
2724
+ }
2725
+
2726
@override
2727
String get lm_studio_download_options => 'Download options';
2728
lib/l10n/app_localizations_es.dart
+14
@@ -2532,6 +2532,12 @@ class AppLocalizationsEs extends AppLocalizations {
2532
@override
2533
String get model_identifier => 'Identificador del modelo';
2534
2535
+ @override
2536
+ String get model_capabilities => 'Capabilities';
2537
+
2538
+ @override
2539
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2540
+
2541
@override
2542
String get not_available => 'No disponible';
2543
@@ -2725,6 +2731,14 @@ class AppLocalizationsEs extends AppLocalizations {
2731
@override
2732
String get lm_studio_reasoning => 'Razonamiento';
2733
2734
+ @override
2735
+ String get openrouter_pricing_free => 'Free';
2736
+
2737
+ @override
2738
+ String openrouter_pricing_tooltip(String input, String output) {
2739
+ return 'Input $input / Output $output per 1M tokens';
2740
+ }
2741
+
2742
@override
2743
String get lm_studio_download_options => 'Opciones de descarga';
2744
lib/l10n/app_localizations_fr.dart
+14
@@ -2514,6 +2514,12 @@ class AppLocalizationsFr extends AppLocalizations {
2514
@override
2515
String get model_identifier => 'Identifier';
2516
2517
+ @override
2518
+ String get model_capabilities => 'Capabilities';
2519
+
2520
+ @override
2521
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2522
+
2523
@override
2524
String get not_available => 'Nont available';
2525
@@ -2714,6 +2720,14 @@ class AppLocalizationsFr extends AppLocalizations {
2720
@override
2721
String get lm_studio_reasoning => 'Reasoning';
2722
2723
+ @override
2724
+ String get openrouter_pricing_free => 'Free';
2725
+
2726
+ @override
2727
+ String openrouter_pricing_tooltip(String input, String output) {
2728
+ return 'Input $input / Output $output per 1M tokens';
2729
+ }
2730
+
2731
@override
2732
String get lm_studio_download_options => 'Télécharger options';
2733
lib/l10n/app_localizations_hi.dart
+14
@@ -2520,6 +2520,12 @@ class AppLocalizationsHi extends AppLocalizations {
2520
@override
2521
String get model_identifier => 'मॉडल पहचानकर्ता';
2522
2523
+ @override
2524
+ String get model_capabilities => 'Capabilities';
2525
+
2526
+ @override
2527
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2528
+
2529
@override
2530
String get not_available => 'अनुपलब्ध';
2531
@@ -2714,6 +2720,14 @@ class AppLocalizationsHi extends AppLocalizations {
2720
@override
2721
String get lm_studio_reasoning => 'तर्क (Reasoning)';
2722
2723
+ @override
2724
+ String get openrouter_pricing_free => 'Free';
2725
+
2726
+ @override
2727
+ String openrouter_pricing_tooltip(String input, String output) {
2728
+ return 'Input $input / Output $output per 1M tokens';
2729
+ }
2730
+
2731
@override
2732
String get lm_studio_download_options => 'डाउनलोड विकल्प';
2733
lib/l10n/app_localizations_it.dart
+14
@@ -2534,6 +2534,12 @@ class AppLocalizationsIt extends AppLocalizations {
2534
@override
2535
String get model_identifier => 'Identificatore modello';
2536
2537
+ @override
2538
+ String get model_capabilities => 'Capabilities';
2539
+
2540
+ @override
2541
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2542
+
2543
@override
2544
String get not_available => 'Non disponibile';
2545
@@ -2727,6 +2733,14 @@ class AppLocalizationsIt extends AppLocalizations {
2733
@override
2734
String get lm_studio_reasoning => 'Ragionamento';
2735
2736
+ @override
2737
+ String get openrouter_pricing_free => 'Free';
2738
+
2739
+ @override
2740
+ String openrouter_pricing_tooltip(String input, String output) {
2741
+ return 'Input $input / Output $output per 1M tokens';
2742
+ }
2743
+
2744
@override
2745
String get lm_studio_download_options => 'Opzioni di download';
2746
lib/l10n/app_localizations_ja.dart
+14
@@ -2472,6 +2472,12 @@ class AppLocalizationsJa extends AppLocalizations {
2472
@override
2473
String get model_identifier => 'モデル識別子';
2474
2475
+ @override
2476
+ String get model_capabilities => 'Capabilities';
2477
+
2478
+ @override
2479
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2480
+
2481
@override
2482
String get not_available => '利用不可';
2483
@@ -2663,6 +2669,14 @@ class AppLocalizationsJa extends AppLocalizations {
2669
@override
2670
String get lm_studio_reasoning => '推論';
2671
2672
+ @override
2673
+ String get openrouter_pricing_free => 'Free';
2674
+
2675
+ @override
2676
+ String openrouter_pricing_tooltip(String input, String output) {
2677
+ return 'Input $input / Output $output per 1M tokens';
2678
+ }
2679
+
2680
@override
2681
String get lm_studio_download_options => 'ダウンロードオプション';
2682
lib/l10n/app_localizations_ru.dart
+14
@@ -2532,6 +2532,12 @@ class AppLocalizationsRu extends AppLocalizations {
2532
@override
2533
String get model_identifier => 'Идентификатор модели';
2534
2535
+ @override
2536
+ String get model_capabilities => 'Capabilities';
2537
+
2538
+ @override
2539
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2540
+
2541
@override
2542
String get not_available => 'Недоступно';
2543
@@ -2726,6 +2732,14 @@ class AppLocalizationsRu extends AppLocalizations {
2732
@override
2733
String get lm_studio_reasoning => 'Рассуждение';
2734
2735
+ @override
2736
+ String get openrouter_pricing_free => 'Free';
2737
+
2738
+ @override
2739
+ String openrouter_pricing_tooltip(String input, String output) {
2740
+ return 'Input $input / Output $output per 1M tokens';
2741
+ }
2742
+
2743
@override
2744
String get lm_studio_download_options => 'Параметры загрузки';
2745
lib/l10n/app_localizations_tr.dart
+14
@@ -2530,6 +2530,12 @@ class AppLocalizationsTr extends AppLocalizations {
2530
@override
2531
String get model_identifier => 'Kimlik (Identifier)';
2532
2533
+ @override
2534
+ String get model_capabilities => 'Capabilities';
2535
+
2536
+ @override
2537
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2538
+
2539
@override
2540
String get not_available => 'Mevcut değil';
2541
@@ -2731,6 +2737,14 @@ class AppLocalizationsTr extends AppLocalizations {
2737
@override
2738
String get lm_studio_reasoning => 'Mantık yürütme';
2739
2740
+ @override
2741
+ String get openrouter_pricing_free => 'Free';
2742
+
2743
+ @override
2744
+ String openrouter_pricing_tooltip(String input, String output) {
2745
+ return 'Input $input / Output $output per 1M tokens';
2746
+ }
2747
+
2748
@override
2749
String get lm_studio_download_options => 'İndirme seçenekleri';
2750
lib/l10n/app_localizations_zh.dart
+14
@@ -2456,6 +2456,12 @@ class AppLocalizationsZh extends AppLocalizations {
2456
@override
2457
String get model_identifier => '模型标识符';
2458
2459
+ @override
2460
+ String get model_capabilities => 'Capabilities';
2461
+
2462
+ @override
2463
+ String get model_api_pricing => 'API pricing (per 1M tokens)';
2464
+
2465
@override
2466
String get not_available => '不可用';
2467
@@ -2645,6 +2651,14 @@ class AppLocalizationsZh extends AppLocalizations {
2651
@override
2652
String get lm_studio_reasoning => '推理能力';
2653
2654
+ @override
2655
+ String get openrouter_pricing_free => 'Free';
2656
+
2657
+ @override
2658
+ String openrouter_pricing_tooltip(String input, String output) {
2659
+ return 'Input $input / Output $output per 1M tokens';
2660
+ }
2661
+
2662
@override
2663
String get lm_studio_download_options => '下载选项';
2664
lib/l10n/untranslated.txt
+55
-11
@@ -20,7 +20,11 @@
20
"add_server_endpoint_subtitle",
21
"model_set_default",
22
"model_clear_default",
23
- "model_default_badge"
23
+ "model_default_badge",
24
+ "model_capabilities",
25
+ "model_api_pricing",
26
+ "openrouter_pricing_free",
27
+ "openrouter_pricing_tooltip"
28
],
29
30
"bn": [
@@ -44,7 +48,11 @@
48
"add_server_endpoint_subtitle",
49
"model_set_default",
50
"model_clear_default",
47
- "model_default_badge"
51
+ "model_default_badge",
52
+ "model_capabilities",
53
+ "model_api_pricing",
54
+ "openrouter_pricing_free",
55
+ "openrouter_pricing_tooltip"
56
],
57
58
"es": [
@@ -68,7 +76,11 @@
76
"add_server_endpoint_subtitle",
77
"model_set_default",
78
"model_clear_default",
71
- "model_default_badge"
79
+ "model_default_badge",
80
+ "model_capabilities",
81
+ "model_api_pricing",
82
+ "openrouter_pricing_free",
83
+ "openrouter_pricing_tooltip"
84
],
85
86
"fr": [
@@ -82,7 +94,11 @@
94
"mcp_import_failed",
95
"model_set_default",
96
"model_clear_default",
85
- "model_default_badge"
97
+ "model_default_badge",
98
+ "model_capabilities",
99
+ "model_api_pricing",
100
+ "openrouter_pricing_free",
101
+ "openrouter_pricing_tooltip"
102
],
103
104
"hi": [
@@ -106,7 +122,11 @@
122
"add_server_endpoint_subtitle",
123
"model_set_default",
124
"model_clear_default",
109
- "model_default_badge"
125
+ "model_default_badge",
126
+ "model_capabilities",
127
+ "model_api_pricing",
128
+ "openrouter_pricing_free",
129
+ "openrouter_pricing_tooltip"
130
],
131
132
"it": [
@@ -130,7 +150,11 @@
150
"add_server_endpoint_subtitle",
151
"model_set_default",
152
"model_clear_default",
133
- "model_default_badge"
153
+ "model_default_badge",
154
+ "model_capabilities",
155
+ "model_api_pricing",
156
+ "openrouter_pricing_free",
157
+ "openrouter_pricing_tooltip"
158
],
159
160
"ja": [
@@ -154,7 +178,11 @@
178
"add_server_endpoint_subtitle",
179
"model_set_default",
180
"model_clear_default",
157
- "model_default_badge"
181
+ "model_default_badge",
182
+ "model_capabilities",
183
+ "model_api_pricing",
184
+ "openrouter_pricing_free",
185
+ "openrouter_pricing_tooltip"
186
],
187
188
"ru": [
@@ -178,7 +206,11 @@
206
"add_server_endpoint_subtitle",
207
"model_set_default",
208
"model_clear_default",
181
- "model_default_badge"
209
+ "model_default_badge",
210
+ "model_capabilities",
211
+ "model_api_pricing",
212
+ "openrouter_pricing_free",
213
+ "openrouter_pricing_tooltip"
214
],
215
216
"tr": [
@@ -192,7 +224,11 @@
224
"mcp_import_failed",
225
"model_set_default",
226
"model_clear_default",
195
- "model_default_badge"
227
+ "model_default_badge",
228
+ "model_capabilities",
229
+ "model_api_pricing",
230
+ "openrouter_pricing_free",
231
+ "openrouter_pricing_tooltip"
232
],
233
234
"zh": [
@@ -216,7 +252,11 @@
252
"add_server_endpoint_subtitle",
253
"model_set_default",
254
"model_clear_default",
219
- "model_default_badge"
255
+ "model_default_badge",
256
+ "model_capabilities",
257
+ "model_api_pricing",
258
+ "openrouter_pricing_free",
259
+ "openrouter_pricing_tooltip"
260
],
261
262
"zh_TW": [
@@ -240,6 +280,10 @@
280
"add_server_endpoint_subtitle",
281
"model_set_default",
282
"model_clear_default",
243
- "model_default_badge"
283
+ "model_default_badge",
284
+ "model_capabilities",
285
+ "model_api_pricing",
286
+ "openrouter_pricing_free",
287
+ "openrouter_pricing_tooltip"
288
]
289
}
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.6.2+67
19
+version: 1.6.3+68
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
test/features/chat/tools/chat_service_adapter_selection_test.dart
+397
@@ -428,6 +428,403 @@ void main() {
428
},
429
);
430
});
431
+
432
+ group('OpenAI-compatible attachment formatting', () {
433
+ late Directory tempDirectory;
434
+
435
+ setUp(() async {
436
+ tempDirectory = await Directory.systemTemp.createTemp(
437
+ 'localmind_openai_attachment_test_',
438
+ );
439
+ });
440
+
441
+ tearDown(() async {
442
+ if (await tempDirectory.exists()) {
443
+ await tempDirectory.delete(recursive: true);
444
+ }
445
+ });
446
+
447
+ test('sends image attachments as base64 data-URL content parts', () async {
448
+ // Minimal PNG and JPEG headers so mime-type detection is exercised.
449
+ final pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02];
450
+ final jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x03, 0x04, 0x05];
451
+ final png = await File('${tempDirectory.path}/photo.png')
452
+ .writeAsBytes(pngBytes);
453
+ final jpeg = await File('${tempDirectory.path}/photo.jpg')
454
+ .writeAsBytes(jpegBytes);
455
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
456
+ final service = OpenAICompatibleChatService(
457
+ Dio()..interceptors.add(interceptor),
458
+ imageCompressionEnabled: false,
459
+ );
460
+
461
+ final responses = await service
462
+ .sendMessage(
463
+ server: _openAiTestServer(),
464
+ modelId: 'vision-model',
465
+ messages: [
466
+ Message(
467
+ id: 'vision-message',
468
+ conversationId: 'conversation',
469
+ role: MessageRole.user,
470
+ content: 'What is shown?',
471
+ attachmentPaths: [png.path, jpeg.path],
472
+ createdAt: DateTime.now(),
473
+ ),
474
+ ],
475
+ params: ChatParameters.defaults(),
476
+ )
477
+ .toList();
478
+
479
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
480
+ final content = (body['messages'] as List).single['content'] as List;
481
+ expect(content, hasLength(3));
482
+ expect(content[0], {'type': 'text', 'text': 'What is shown?'});
483
+ expect(
484
+ content[1],
485
+ {
486
+ 'type': 'image_url',
487
+ 'image_url': {
488
+ 'url': 'data:image/png;base64,${base64Encode(pngBytes)}',
489
+ },
490
+ },
491
+ );
492
+ expect(
493
+ content[2],
494
+ {
495
+ 'type': 'image_url',
496
+ 'image_url': {
497
+ 'url': 'data:image/jpeg;base64,${base64Encode(jpegBytes)}',
498
+ },
499
+ },
500
+ );
501
+ expect(responses.last.type, ChatResponseType.done);
502
+ });
503
+
504
+ test('keeps messages without attachments as plain string content', () async {
505
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
506
+ final service = OpenAICompatibleChatService(
507
+ Dio()..interceptors.add(interceptor),
508
+ );
509
+
510
+ await service
511
+ .sendMessage(
512
+ server: _openAiTestServer(),
513
+ modelId: 'text-model',
514
+ messages: [
515
+ Message(
516
+ id: 'text-message',
517
+ conversationId: 'conversation',
518
+ role: MessageRole.user,
519
+ content: 'hello',
520
+ createdAt: DateTime.now(),
521
+ ),
522
+ ],
523
+ params: ChatParameters.defaults(),
524
+ )
525
+ .toList();
526
+
527
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
528
+ final message = (body['messages'] as List).single as Map<String, dynamic>;
529
+ expect(message['content'], 'hello');
530
+ });
531
+
532
+ test('inlines text attachment contents into the user message', () async {
533
+ final textFile = await File(
534
+ '${tempDirectory.path}/notes.md',
535
+ ).writeAsString('Attachment text');
536
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
537
+ final service = OpenAICompatibleChatService(
538
+ Dio()..interceptors.add(interceptor),
539
+ );
540
+
541
+ await service
542
+ .sendMessage(
543
+ server: _openAiTestServer(),
544
+ modelId: 'text-model',
545
+ messages: [
546
+ Message(
547
+ id: 'text-message',
548
+ conversationId: 'conversation',
549
+ role: MessageRole.user,
550
+ content: 'Summarize this',
551
+ attachmentPaths: [textFile.path],
552
+ createdAt: DateTime.now(),
553
+ ),
554
+ ],
555
+ params: ChatParameters.defaults(),
556
+ )
557
+ .toList();
558
+
559
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
560
+ final message = (body['messages'] as List).single as Map<String, dynamic>;
561
+ expect(
562
+ message['content'],
563
+ 'Summarize this\n\n--- notes.md ---\nAttachment text',
564
+ );
565
+ });
566
+
567
+ test('skips missing image attachments without aborting the request', () async {
568
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
569
+ final service = OpenAICompatibleChatService(
570
+ Dio()..interceptors.add(interceptor),
571
+ );
572
+
573
+ final responses = await service
574
+ .sendMessage(
575
+ server: _openAiTestServer(),
576
+ modelId: 'vision-model',
577
+ messages: [
578
+ Message(
579
+ id: 'missing-image-message',
580
+ conversationId: 'conversation',
581
+ role: MessageRole.user,
582
+ content: 'Continue without the missing image',
583
+ attachmentPaths: ['${tempDirectory.path}/missing.png'],
584
+ createdAt: DateTime.now(),
585
+ ),
586
+ ],
587
+ params: ChatParameters.defaults(),
588
+ )
589
+ .toList();
590
+
591
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
592
+ final message = (body['messages'] as List).single as Map<String, dynamic>;
593
+ expect(message['content'], 'Continue without the missing image');
594
+ expect(responses.last.type, ChatResponseType.done);
595
+ });
596
+ });
597
+
598
+ group('OpenRouter attachment formatting', () {
599
+ late Directory tempDirectory;
600
+
601
+ setUp(() async {
602
+ tempDirectory = await Directory.systemTemp.createTemp(
603
+ 'localmind_openrouter_attachment_test_',
604
+ );
605
+ });
606
+
607
+ tearDown(() async {
608
+ if (await tempDirectory.exists()) {
609
+ await tempDirectory.delete(recursive: true);
610
+ }
611
+ });
612
+
613
+ test('sends image attachments as base64 data-URL content parts', () async {
614
+ final pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02];
615
+ final png = await File('${tempDirectory.path}/photo.png')
616
+ .writeAsBytes(pngBytes);
617
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
618
+ final service = OpenRouterChatService(
619
+ Dio()..interceptors.add(interceptor),
620
+ imageCompressionEnabled: false,
621
+ );
622
+
623
+ final responses = await service
624
+ .sendMessage(
625
+ server: _openRouterTestServer(),
626
+ modelId: 'vision-model',
627
+ messages: [
628
+ Message(
629
+ id: 'vision-message',
630
+ conversationId: 'conversation',
631
+ role: MessageRole.user,
632
+ content: 'What is shown?',
633
+ attachmentPaths: [png.path],
634
+ createdAt: DateTime.now(),
635
+ ),
636
+ ],
637
+ params: ChatParameters.defaults(),
638
+ )
639
+ .toList();
640
+
641
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
642
+ final content = (body['messages'] as List).single['content'] as List;
643
+ expect(content, hasLength(2));
644
+ expect(content[0], {'type': 'text', 'text': 'What is shown?'});
645
+ expect(
646
+ content[1],
647
+ {
648
+ 'type': 'image_url',
649
+ 'image_url': {
650
+ 'url': 'data:image/png;base64,${base64Encode(pngBytes)}',
651
+ },
652
+ },
653
+ );
654
+ expect(responses.last.type, ChatResponseType.done);
655
+ });
656
+
657
+ test('inlines text attachment contents into the user message', () async {
658
+ final textFile = await File(
659
+ '${tempDirectory.path}/notes.txt',
660
+ ).writeAsString('attachment text');
661
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
662
+ final service = OpenRouterChatService(
663
+ Dio()..interceptors.add(interceptor),
664
+ );
665
+
666
+ await service
667
+ .sendMessage(
668
+ server: _openRouterTestServer(),
669
+ modelId: 'text-model',
670
+ messages: [
671
+ Message(
672
+ id: 'text-message',
673
+ conversationId: 'conversation',
674
+ role: MessageRole.user,
675
+ content: 'Summarize this',
676
+ attachmentPaths: [textFile.path],
677
+ createdAt: DateTime.now(),
678
+ ),
679
+ ],
680
+ params: ChatParameters.defaults(),
681
+ )
682
+ .toList();
683
+
684
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
685
+ final message = (body['messages'] as List).single as Map<String, dynamic>;
686
+ expect(
687
+ message['content'],
688
+ 'Summarize this\n\n--- notes.txt ---\nattachment text',
689
+ );
690
+ });
691
+ });
692
+
693
+ group('OpenAI-compatible & OpenRouter assistant prefill handling', () {
694
+ test('strips trailing empty assistant when not continuing generation', () async {
695
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
696
+ final service = OpenAICompatibleChatService(
697
+ Dio()..interceptors.add(interceptor),
698
+ );
699
+
700
+ await service
701
+ .sendMessage(
702
+ server: _openAiTestServer(),
703
+ modelId: 'text-model',
704
+ messages: [
705
+ Message(
706
+ id: 'u1',
707
+ conversationId: 'c',
708
+ role: MessageRole.user,
709
+ content: 'hi',
710
+ createdAt: DateTime.now(),
711
+ ),
712
+ Message(
713
+ id: 'a1',
714
+ conversationId: 'c',
715
+ role: MessageRole.assistant,
716
+ content: '',
717
+ createdAt: DateTime.now(),
718
+ ),
719
+ ],
720
+ params: ChatParameters.defaults(),
721
+ )
722
+ .toList();
723
+
724
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
725
+ final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
726
+ expect(msgs, hasLength(1));
727
+ expect(msgs.last['role'], 'user');
728
+ });
729
+
730
+ test('keeps trailing assistant prefill when continuing generation (OpenRouter)', () async {
731
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
732
+ final service = OpenRouterChatService(
733
+ Dio()..interceptors.add(interceptor),
734
+ );
735
+
736
+ await service
737
+ .sendMessage(
738
+ server: _openRouterTestServer(),
739
+ modelId: 'text-model',
740
+ messages: [
741
+ Message(
742
+ id: 'u1',
743
+ conversationId: 'c',
744
+ role: MessageRole.user,
745
+ content: 'hi',
746
+ createdAt: DateTime.now(),
747
+ ),
748
+ Message(
749
+ id: 'a1',
750
+ conversationId: 'c',
751
+ role: MessageRole.assistant,
752
+ content: 'Sure, ',
753
+ createdAt: DateTime.now(),
754
+ ),
755
+ ],
756
+ params: ChatParameters.defaults(),
757
+ continueGeneration: true,
758
+ )
759
+ .toList();
760
+
761
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
762
+ final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
763
+ expect(msgs, hasLength(2));
764
+ expect(msgs.last['role'], 'assistant');
765
+ expect(msgs.last['content'], 'Sure, ');
766
+ });
767
+
768
+ test('strips trailing empty assistant on OpenRouter too', () async {
769
+ final interceptor = CapturingStreamInterceptor(['data: [DONE]']);
770
+ final service = OpenRouterChatService(
771
+ Dio()..interceptors.add(interceptor),
772
+ );
773
+
774
+ await service
775
+ .sendMessage(
776
+ server: _openRouterTestServer(),
777
+ modelId: 'text-model',
778
+ messages: [
779
+ Message(
780
+ id: 'u1',
781
+ conversationId: 'c',
782
+ role: MessageRole.user,
783
+ content: 'hi',
784
+ createdAt: DateTime.now(),
785
+ ),
786
+ Message(
787
+ id: 'a1',
788
+ conversationId: 'c',
789
+ role: MessageRole.assistant,
790
+ content: '',
791
+ createdAt: DateTime.now(),
792
+ ),
793
+ ],
794
+ params: ChatParameters.defaults(),
795
+ )
796
+ .toList();
797
+
798
+ final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
799
+ final msgs = (body['messages'] as List).cast<Map<String, dynamic>>();
800
+ expect(msgs, hasLength(1));
801
+ expect(msgs.last['role'], 'user');
802
+ });
803
+ });
804
+}
805
+
806
+Server _openAiTestServer() {
807
+ return Server(
808
+ id: 'test-openai',
809
+ name: 'Test OpenAI',
810
+ type: ServerType.openAICompatible,
811
+ host: 'localhost',
812
+ port: 8080,
813
+ createdAt: DateTime.now(),
814
+ lastConnectedAt: DateTime.now(),
815
+ );
816
+}
817
+
818
+Server _openRouterTestServer() {
819
+ return Server(
820
+ id: 'test-openrouter',
821
+ name: 'Test OpenRouter',
822
+ type: ServerType.openRouter,
823
+ host: 'localhost',
824
+ port: 8080,
825
+ createdAt: DateTime.now(),
826
+ lastConnectedAt: DateTime.now(),
827
+ );
828
}
829
830
Server _ollamaTestServer() {
test/features/servers/server_api_service_test.dart
+140
@@ -500,4 +500,144 @@ void main() {
500
);
501
});
502
});
503
+
504
+ group('ServerApiService - OpenRouter model parsing', () {
505
+ late Server openRouterServer;
506
+
507
+ setUp(() {
508
+ openRouterServer = Server(
509
+ id: 'test-openrouter',
510
+ name: 'Test OpenRouter',
511
+ type: ServerType.openRouter,
512
+ host: 'openrouter.ai',
513
+ port: 443,
514
+ apiKey: 'test-key',
515
+ createdAt: DateTime.now(),
516
+ lastConnectedAt: DateTime.now(),
517
+ );
518
+ });
519
+
520
+ test('parses capabilities and per-1M pricing for OpenRouter models', () async {
521
+ final data = {
522
+ 'data': [
523
+ {
524
+ 'id': 'openai/gpt-4o',
525
+ 'name': 'OpenAI: GPT-4o',
526
+ 'created': 1715558400,
527
+ 'context_length': 128000,
528
+ 'architecture': {
529
+ 'modality': 'text+image+file->text',
530
+ 'input_modalities': ['text', 'image', 'file'],
531
+ 'output_modalities': ['text'],
532
+ 'tokenizer': 'GPT',
533
+ 'instruct_type': null,
534
+ },
535
+ 'pricing': {'prompt': '0.0000025', 'completion': '0.00001'},
536
+ },
537
+ {
538
+ 'id': 'deepseek/deepseek-reasoner',
539
+ 'name': 'DeepSeek: R1',
540
+ 'context_length': 163840,
541
+ 'architecture': {
542
+ 'modality': 'text->text',
543
+ 'input_modalities': ['text'],
544
+ 'output_modalities': ['text'],
545
+ 'tokenizer': 'DeepSeek',
546
+ },
547
+ 'reasoning': {'mandatory': true},
548
+ 'supported_parameters': ['tools', 'reasoning', 'temperature'],
549
+ },
550
+ {
551
+ 'id': 'meta-llama/llama-3.1-8b-instruct:free',
552
+ 'name': 'Meta: Llama 3.1 8B (Free)',
553
+ 'context_length': 131072,
554
+ 'architecture': {
555
+ 'modality': 'text->text',
556
+ 'input_modalities': ['text'],
557
+ 'output_modalities': ['text'],
558
+ 'tokenizer': 'Llama3',
559
+ },
560
+ 'pricing': {'prompt': '0', 'completion': '0'},
561
+ },
562
+ ],
563
+ };
564
+
565
+ final service = ServerApiService(
566
+ Dio()..interceptors.add(TestInterceptor(data)),
567
+ );
568
+ final models = await service.fetchModels(openRouterServer);
569
+
570
+ expect(models, hasLength(3));
571
+ final byId = {for (final m in models) m.id: m};
572
+
573
+ final gpt4o = byId['openai/gpt-4o']!;
574
+ expect(gpt4o.supportsVision, isTrue);
575
+ expect(gpt4o.inputPricePerMillion, closeTo(2.5, 0.0001));
576
+ expect(gpt4o.outputPricePerMillion, closeTo(10.0, 0.0001));
577
+ expect(gpt4o.pricingLabel, '\$2.50/\$10.00');
578
+
579
+ final reasoner = byId['deepseek/deepseek-reasoner']!;
580
+ expect(reasoner.supportsReasoning, isTrue);
581
+ expect(reasoner.supportsToolUse, isTrue);
582
+ expect(reasoner.supportsVision, isFalse);
583
+
584
+ final free = byId['meta-llama/llama-3.1-8b-instruct:free']!;
585
+ expect(free.isPricingFree, isTrue);
586
+ expect(free.pricingLabel, 'Free');
587
+ });
588
+
589
+ test('detects vision via modality when input_modalities is absent', () async {
590
+ final data = {
591
+ 'data': [
592
+ {
593
+ 'id': 'google/gemini-pro',
594
+ 'name': 'Google: Gemini Pro',
595
+ 'context_length': 32768,
596
+ 'architecture': {
597
+ 'modality': 'text+image->text',
598
+ 'tokenizer': 'Gemini',
599
+ },
600
+ },
601
+ ],
602
+ };
603
+
604
+ final service = ServerApiService(
605
+ Dio()..interceptors.add(TestInterceptor(data)),
606
+ );
607
+ final models = await service.fetchModels(openRouterServer);
608
+
609
+ expect(models, hasLength(1));
610
+ expect(models.single.supportsVision, isTrue);
611
+ expect(models.single.supportsReasoning, isFalse);
612
+ expect(models.single.inputPricePerMillion, isNull);
613
+ expect(models.single.pricingLabel, isNull);
614
+ });
615
+
616
+ test('treats non-finite pricing values as unknown', () async {
617
+ final data = {
618
+ 'data': [
619
+ {
620
+ 'id': 'weird/model',
621
+ 'name': 'Weird Model',
622
+ 'context_length': 8192,
623
+ 'architecture': {
624
+ 'modality': 'text->text',
625
+ 'tokenizer': 'X',
626
+ },
627
+ 'pricing': {'prompt': '1e309', 'completion': 'Infinity'},
628
+ },
629
+ ],
630
+ };
631
+
632
+ final service = ServerApiService(
633
+ Dio()..interceptors.add(TestInterceptor(data)),
634
+ );
635
+ final models = await service.fetchModels(openRouterServer);
636
+
637
+ expect(models, hasLength(1));
638
+ expect(models.single.inputPricePerMillion, isNull);
639
+ expect(models.single.outputPricePerMillion, isNull);
640
+ expect(models.single.pricingLabel, isNull);
641
+ });
642
+ });
643
}