refactor: centralize API authorization header logic into buildServerAuthHeaders helper, fixes #29
refactor: centralize API authorization header logic into buildServerAuthHeaders helper, fixes #29
abdulmominsakib committed
Jun 26, 2026 at 00:21 UTC
ede57380f5b8ce52b7b3e715bf6cb05ff4cfe579
3 files changed
+142
-69
lib/features/chat/data/chat_service.dart
+66
-22
@@ -197,8 +197,7 @@ class LMStudioChatService implements ChatService {
197
responseType: ResponseType.stream,
198
headers: {
199
'Content-Type': 'application/json',
200
- if (server.apiKey != null)
201
- 'Authorization': 'Bearer ${server.apiKey}',
200
+ ...buildServerAuthHeaders(server),
201
},
202
),
203
cancelToken: _cancelToken,
@@ -246,7 +245,9 @@ class LMStudioChatService implements ChatService {
245
type: ChatResponseType.error,
246
content: _handleChatError(e),
247
);
248
+ return;
249
}
250
+ yield const ChatResponse(type: ChatResponseType.done);
251
}
252
253
Stream<ChatResponse> _handleSseEvent(
@@ -393,7 +394,7 @@ class LMStudioChatService implements ChatService {
394
final error = json['error'] as Map<String, dynamic>?;
395
if (error != null) {
396
yield ChatResponse(
396
- type: ChatResponseType.message,
397
+ type: ChatResponseType.error,
398
content: 'Error: ${error['message'] ?? 'Unknown error'}',
399
);
400
}
@@ -466,15 +467,14 @@ class OpenAICompatibleChatService implements ChatService {
467
_cancelToken = CancelToken();
468
final toolAdapter = OpenAiToolAdapter();
469
469
- final apiMessages = messages
470
- .map((m) => {'role': _roleToString(m.role), 'content': m.content})
471
- .toList();
470
+ final apiMessages = messages.map(_messageToApiMap).toList();
471
// Strip trailing empty assistant messages to avoid "prefill incompatible
472
// with enable_thinking" errors from servers running thinking models
473
while (apiMessages.isNotEmpty &&
474
apiMessages.last['role'] == 'assistant' &&
475
(apiMessages.last['content'] == null ||
477
- apiMessages.last['content']!.isEmpty)) {
476
+ (apiMessages.last['content'] is String &&
477
+ (apiMessages.last['content'] as String).isEmpty))) {
478
apiMessages.removeLast();
479
}
480
@@ -506,8 +506,7 @@ class OpenAICompatibleChatService implements ChatService {
506
responseType: ResponseType.stream,
507
headers: {
508
'Content-Type': 'application/json',
509
- if (server.apiKey != null)
510
- 'Authorization': 'Bearer ${server.apiKey}',
509
+ ...buildServerAuthHeaders(server),
510
},
511
),
512
cancelToken: _cancelToken,
@@ -574,8 +573,14 @@ class OpenAICompatibleChatService implements ChatService {
573
574
// Check for errors in the response
575
if (json['error'] != null) {
577
- final error = json['error'] as Map<String, dynamic>;
578
- final errorMsg = error['message'] ?? 'Unknown API error';
576
+ _timeoutTimer?.cancel();
577
+ final error = json['error'];
578
+ String errorMsg;
579
+ if (error is Map) {
580
+ errorMsg = (error['message'] ?? 'Unknown API error') as String;
581
+ } else {
582
+ errorMsg = error.toString();
583
+ }
584
Log.error('OpenAICompatible mid-stream error: $errorMsg');
585
yield ChatResponse(
586
type: ChatResponseType.error,
@@ -658,9 +663,11 @@ class OpenAICompatibleChatService implements ChatService {
663
type: ChatResponseType.error,
664
content: _handleChatError(e),
665
);
666
+ return;
667
}
668
669
_timeoutTimer?.cancel();
670
+ yield const ChatResponse(type: ChatResponseType.done);
671
Log.debug('OpenAICompatible: Stream ended');
672
}
673
@@ -692,9 +699,7 @@ class OllamaChatService implements ChatService {
699
700
final body = {
701
'model': modelId,
695
- 'messages': messages
696
- .map((m) => {'role': _roleToString(m.role), 'content': m.content})
697
- .toList(),
702
+ 'messages': messages.map(_messageToApiMap).toList(),
703
'stream': true,
704
'options': {
705
'temperature': params.temperature,
@@ -714,7 +719,13 @@ class OllamaChatService implements ChatService {
719
final response = await _dio.post<ResponseBody>(
720
server.chatEndpoint,
721
data: body,
717
- options: Options(responseType: ResponseType.stream),
722
+ options: Options(
723
+ responseType: ResponseType.stream,
724
+ headers: {
725
+ 'Content-Type': 'application/json',
726
+ ...buildServerAuthHeaders(server),
727
+ },
728
+ ),
729
cancelToken: _cancelToken,
730
);
731
@@ -765,7 +776,7 @@ class OllamaChatService implements ChatService {
776
return;
777
}
778
} catch (e) {
768
- // ignore: empty_catches
779
+ Log.error('Ollama chunk parsing error: $e');
780
}
781
}
782
}
@@ -776,7 +787,9 @@ class OllamaChatService implements ChatService {
787
type: ChatResponseType.error,
788
content: _handleChatError(e),
789
);
790
+ return;
791
}
792
+ yield const ChatResponse(type: ChatResponseType.done);
793
}
794
795
@override
@@ -807,9 +820,7 @@ class OpenRouterChatService implements ChatService {
820
821
final body = {
822
'model': modelId,
810
- 'messages': messages
811
- .map((m) => {'role': _roleToString(m.role), 'content': m.content})
812
- .toList(),
823
+ 'messages': messages.map(_messageToApiMap).toList(),
824
'temperature': params.temperature,
825
'top_p': params.topP,
826
'max_tokens': params.maxTokens,
@@ -835,7 +846,7 @@ class OpenRouterChatService implements ChatService {
846
responseType: ResponseType.stream,
847
headers: {
848
'Content-Type': 'application/json',
838
- 'Authorization': 'Bearer ${server.apiKey}',
849
+ ...buildServerAuthHeaders(server),
850
'HTTP-Referer': 'https://localmind.app',
851
'X-Title': 'LocalMind',
852
},
@@ -904,8 +915,14 @@ class OpenRouterChatService implements ChatService {
915
916
// Check for errors in the response
917
if (json['error'] != null) {
907
- final error = json['error'] as Map<String, dynamic>;
908
- final errorMsg = error['message'] ?? 'Unknown API error';
918
+ _timeoutTimer?.cancel();
919
+ final error = json['error'];
920
+ String errorMsg;
921
+ if (error is Map) {
922
+ errorMsg = (error['message'] ?? 'Unknown API error') as String;
923
+ } else {
924
+ errorMsg = error.toString();
925
+ }
926
Log.error('OpenRouter mid-stream error: $errorMsg');
927
yield ChatResponse(
928
type: ChatResponseType.error,
@@ -993,9 +1010,11 @@ class OpenRouterChatService implements ChatService {
1010
type: ChatResponseType.error,
1011
content: _handleChatError(e),
1012
);
1013
+ return;
1014
}
1015
1016
_timeoutTimer?.cancel();
1017
+ yield const ChatResponse(type: ChatResponseType.done);
1018
Log.debug('OpenRouter: Stream ended');
1019
}
1020
@@ -1065,6 +1084,31 @@ String _roleToString(MessageRole role) {
1084
}
1085
}
1086
1087
+Map<String, dynamic> _messageToApiMap(Message m) {
1088
+ final map = <String, dynamic>{
1089
+ 'role': _roleToString(m.role),
1090
+ 'content': m.content,
1091
+ };
1092
+ if (m.role == MessageRole.tool && m.toolCallId != null) {
1093
+ map['tool_call_id'] = m.toolCallId;
1094
+ }
1095
+ if (m.role == MessageRole.assistant &&
1096
+ m.toolCalls != null &&
1097
+ m.toolCalls!.isNotEmpty) {
1098
+ map['tool_calls'] = m.toolCalls!.map((tc) {
1099
+ return {
1100
+ 'id': tc.id,
1101
+ 'type': 'function',
1102
+ 'function': {
1103
+ 'name': tc.toolName,
1104
+ 'arguments': jsonEncode(tc.arguments),
1105
+ },
1106
+ };
1107
+ }).toList();
1108
+ }
1109
+ return map;
1110
+}
1111
+
1112
/// Returns the streaming-parsing adapter for a server type.
1113
///
1114
/// OpenAI-compatible and Ollama servers use adapter-based tool call parsing
lib/features/servers/data/models/server.dart
+15
@@ -82,10 +82,23 @@ String buildServerBaseUrl(String host, int port, ServerType type) {
82
return '';
83
}
84
85
+ if (port <= 0) {
86
+ return 'http://$trimmedHost';
87
+ }
88
+
89
final protocol = (port == 443) ? 'https' : 'http';
90
return '$protocol://$trimmedHost:$port';
91
}
92
93
+/// Authorization header for [server], or an empty map when no key is set so
94
+/// callers can spread it unconditionally into their headers map.
95
+Map<String, String> buildServerAuthHeaders(Server server) {
96
+ if (server.apiKey?.isNotEmpty ?? false) {
97
+ return {'Authorization': 'Bearer ${server.apiKey}'};
98
+ }
99
+ return const {};
100
+}
101
+
102
class Server {
103
final String id;
104
final String name;
@@ -117,6 +130,8 @@ class Server {
130
return buildServerBaseUrl(host, port, type);
131
}
132
133
+ Map<String, String> get authHeaders => buildServerAuthHeaders(this);
134
+
135
String get chatEndpoint {
136
switch (type) {
137
case ServerType.lmStudio:
lib/features/servers/data/server_api_service.dart
+61
-47
@@ -15,7 +15,7 @@ class ServerApiService {
15
final response = await _dio.get(
16
server.modelsEndpoint,
17
options: Options(
18
- headers: _getAuthHeaders(server),
18
+ headers: buildServerAuthHeaders(server),
19
validateStatus: (status) => status != null && status < 500,
20
),
21
);
@@ -30,7 +30,7 @@ class ServerApiService {
30
try {
31
await _dio.head(
32
server.baseUrl,
33
- options: Options(headers: _getAuthHeaders(server)),
33
+ options: Options(headers: buildServerAuthHeaders(server)),
34
);
35
stopwatch.stop();
36
return stopwatch.elapsedMilliseconds;
@@ -43,7 +43,7 @@ class ServerApiService {
43
try {
44
final response = await _dio.get(
45
server.modelsEndpoint,
46
- options: Options(headers: _getAuthHeaders(server)),
46
+ options: Options(headers: buildServerAuthHeaders(server)),
47
);
48
49
switch (server.type) {
@@ -71,7 +71,7 @@ class ServerApiService {
71
try {
72
final response = await _dio.get(
73
server.runningModelsEndpoint,
74
- options: Options(headers: _getAuthHeaders(server)),
74
+ options: Options(headers: buildServerAuthHeaders(server)),
75
);
76
77
switch (server.type) {
@@ -103,7 +103,7 @@ class ServerApiService {
103
await _dio.post(
104
server.loadModelEndpoint,
105
data: {'model': modelId},
106
- options: Options(headers: _getAuthHeaders(server)),
106
+ options: Options(headers: buildServerAuthHeaders(server)),
107
);
108
} catch (_) {
109
// Not all OpenAI-compatible servers support this endpoint
@@ -133,7 +133,7 @@ class ServerApiService {
133
final response = await _dio.post(
134
server.loadModelEndpoint,
135
data: {'model': modelId, 'echo_load_config': true},
136
- options: Options(headers: _getAuthHeaders(server)),
136
+ options: Options(headers: buildServerAuthHeaders(server)),
137
);
138
return response.data['instance_id'] as String?;
139
} catch (_) {
@@ -167,7 +167,7 @@ class ServerApiService {
167
await _dio.post(
168
server.unloadModelEndpoint,
169
data: {'instance_id': instanceId ?? modelId},
170
- options: Options(headers: _getAuthHeaders(server)),
170
+ options: Options(headers: buildServerAuthHeaders(server)),
171
);
172
} catch (_) {
173
// Not all OpenAI-compatible servers support this endpoint
@@ -181,7 +181,7 @@ class ServerApiService {
181
'keep_alive': 0,
182
'prompt': '',
183
},
184
- options: Options(headers: _getAuthHeaders(server)),
184
+ options: Options(headers: buildServerAuthHeaders(server)),
185
);
186
break;
187
case ServerType.openRouter:
@@ -192,8 +192,9 @@ class ServerApiService {
192
193
Set<String> _parseRunningOpenAICompatibleModels(dynamic data) {
194
final runningModels = <String>{};
195
+ if (data == null) return runningModels;
196
final modelItems = data['models'] ?? data['data'];
196
- if (modelItems != null && modelItems is List) {
197
+ if (modelItems is List) {
198
for (final item in modelItems) {
199
if (item is! Map) continue;
200
final id = item['key']?.toString() ??
@@ -219,28 +220,25 @@ class ServerApiService {
220
221
Set<String> _parseRunningOllamaModels(dynamic data) {
222
final runningModels = <String>{};
222
- if (data['models'] != null) {
223
- for (final item in data['models']) {
224
- runningModels.add(item['name'] as String? ?? '');
223
+ final models = data['models'];
224
+ if (models is List) {
225
+ for (final item in models) {
226
+ if (item is! Map) continue;
227
+ final name = item['name'];
228
+ if (name != null) runningModels.add(name.toString());
229
}
230
}
231
return runningModels;
232
}
233
230
- Map<String, String>? _getAuthHeaders(Server server) {
231
- if (server.apiKey != null && server.apiKey!.isNotEmpty) {
232
- return {'Authorization': 'Bearer ${server.apiKey}'};
233
- }
234
- return null;
235
- }
236
-
234
List<ModelInfo> _parseOpenAICompatibleModels(dynamic data, Server server) {
235
final List<ModelInfo> models = [];
236
+ if (data == null) return models;
237
238
// Build a lookup from the OpenAI-style 'data' array for metadata
239
// that may be missing from the 'models' array (e.g. llama.cpp)
240
final Map<String, Map<String, dynamic>> metaById = {};
243
- if (data['data'] != null && data['data'] is List) {
241
+ if (data['data'] is List) {
242
for (final item in data['data']) {
243
if (item is Map) {
244
final id = item['id']?.toString() ?? '';
@@ -327,14 +325,17 @@ class ServerApiService {
325
326
List<ModelInfo> _parseOllamaModels(dynamic data, Server server) {
327
final List<ModelInfo> models = [];
330
- if (data['models'] != null) {
331
- for (final item in data['models']) {
332
- final details = item['details'] ?? {};
328
+ final modelsData = data['models'];
329
+ if (modelsData is List) {
330
+ for (final item in modelsData) {
331
+ if (item is! Map) continue;
332
+ final detailsVal = item['details'];
333
+ final details = (detailsVal is Map) ? detailsVal : <String, dynamic>{};
334
final paramSize = details['parameter_size'] as String? ?? '';
335
models.add(
336
ModelInfo(
336
- id: item['name'] ?? '',
337
- name: _formatModelName(item['name'] ?? ''),
337
+ id: item['name']?.toString() ?? '',
338
+ name: _formatModelName(item['name']?.toString() ?? ''),
339
fileSize: item['size'] as int?,
340
parameterCount: _parseParameterSize(paramSize),
341
quantization: details['quantization_level'] as String?,
@@ -342,7 +343,7 @@ class ServerApiService {
343
serverType: server.type,
344
serverId: server.id,
345
modifiedAt: item['modified_at'] != null
345
- ? DateTime.tryParse(item['modified_at'])
346
+ ? DateTime.tryParse(item['modified_at']?.toString() ?? '')
347
: null,
348
),
349
);
@@ -353,26 +354,28 @@ class ServerApiService {
354
355
List<ModelInfo> _parseOpenRouterModels(dynamic data, Server server) {
356
final List<ModelInfo> models = [];
356
- if (data['data'] != null) {
357
- for (final item in data['data']) {
358
- final modality = item['architecture']?['modality'] as String?;
359
- final isTextModel = modality == null ||
360
- modality.isEmpty ||
361
- !modality.contains('->') ||
362
- modality.split('->').last.contains('text');
363
- if (isTextModel) {
364
- models.add(
365
- ModelInfo(
366
- id: item['id'] ?? '',
367
- name: item['name'] ?? '',
368
- description: item['description'] as String?,
369
- contextLength: item['context_length'] as int?,
370
- architecture: item['architecture']?['tokenizer'] as String?,
371
- serverType: server.type,
372
- serverId: server.id,
373
- ),
374
- );
375
- }
357
+ if (data == null) return models;
358
+ final items = data['data'];
359
+ if (items is! List) return models;
360
+ for (final item in items) {
361
+ if (item is! Map) continue;
362
+ final modality = item['architecture']?['modality'] as String?;
363
+ final isTextModel = modality == null ||
364
+ modality.isEmpty ||
365
+ !modality.contains('->') ||
366
+ modality.split('->').last.contains('text');
367
+ if (isTextModel) {
368
+ models.add(
369
+ ModelInfo(
370
+ id: item['id']?.toString() ?? '',
371
+ name: item['name']?.toString() ?? '',
372
+ description: item['description'] as String?,
373
+ contextLength: item['context_length'] as int?,
374
+ architecture: item['architecture']?['tokenizer'] as String?,
375
+ serverType: server.type,
376
+ serverId: server.id,
377
+ ),
378
+ );
379
}
380
}
381
return models;
@@ -390,7 +393,18 @@ class ServerApiService {
393
}
394
395
double? _parseParameterSize(String size) {
393
- final cleaned = size.replaceAll('B', '').replaceAll('b', '').trim();
396
+ final cleaned = size.trim();
397
+ if (cleaned.endsWith('B') || cleaned.endsWith('b')) {
398
+ return double.tryParse(
399
+ cleaned.substring(0, cleaned.length - 1).trim(),
400
+ );
401
+ }
402
+ if (cleaned.endsWith('M') || cleaned.endsWith('m')) {
403
+ final val = double.tryParse(
404
+ cleaned.substring(0, cleaned.length - 1).trim(),
405
+ );
406
+ return val != null ? val / 1000 : null;
407
+ }
408
return double.tryParse(cleaned);
409
}
410