@tduncan / Cortana / commits / b417f89

feat: enable image compression and file content inlining for OpenAI and OpenRouter chat services and add localization for model capabilities and pricing.

abdulmominsakib committed Aug 6, 2026 at 15:48 UTC b417f893ef1fb1c335f488c0e6d306473faf0da0
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 }