@tduncan / Cortana / commits / ede5738

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