| 1 | import '../../../../core/constants/app_constants.dart'; |
| 2 | import '../../../../core/models/enums.dart'; |
| 3 | |
| 4 | bool _hasExplicitHttpScheme(String input) { |
| 5 | final trimmed = input.trim().toLowerCase(); |
| 6 | return trimmed.startsWith('http://') || trimmed.startsWith('https://'); |
| 7 | } |
| 8 | |
| 9 | Uri? parseServerAddressInput(String input) { |
| 10 | final trimmed = input.trim(); |
| 11 | if (trimmed.isEmpty) return null; |
| 12 | |
| 13 | final candidate = _hasExplicitHttpScheme(trimmed) |
| 14 | ? trimmed |
| 15 | : 'http://$trimmed'; |
| 16 | final uri = Uri.tryParse(candidate); |
| 17 | if (uri == null || uri.host.isEmpty) return null; |
| 18 | if (uri.scheme != 'http' && uri.scheme != 'https') return null; |
| 19 | if (uri.hasQuery || uri.hasFragment) return null; |
| 20 | return uri; |
| 21 | } |
| 22 | |
| 23 | /// Extracts a normalized path prefix from a parsed URI. |
| 24 | /// Returns `''` for empty path, root `/`, or paths with trailing slashes. |
| 25 | /// Returns the path as-is otherwise (e.g. `/ollama`). |
| 26 | String extractPathPrefix(Uri uri) { |
| 27 | return normalizeServerPathPrefix(uri.path.isEmpty ? '' : uri.path); |
| 28 | } |
| 29 | |
| 30 | bool isHttpsAddressInput(String input) { |
| 31 | return input.trim().toLowerCase().startsWith('https://'); |
| 32 | } |
| 33 | |
| 34 | Uri _normalizeNetworkUri(Uri uri, {int? port}) { |
| 35 | return Uri( |
| 36 | scheme: uri.scheme, |
| 37 | userInfo: uri.userInfo, |
| 38 | host: uri.host, |
| 39 | port: port ?? (uri.hasPort ? uri.port : null), |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | String displayServerAddress( |
| 44 | String host, |
| 45 | int port, |
| 46 | ServerType type, [ |
| 47 | String? pathPrefix, |
| 48 | ]) { |
| 49 | if (type == ServerType.openRouter) { |
| 50 | return 'openrouter.ai'; |
| 51 | } |
| 52 | if (type == ServerType.requesty) { |
| 53 | return 'router.requesty.ai'; |
| 54 | } |
| 55 | if (type == ServerType.ollamaCloud) { |
| 56 | return 'ollama.com'; |
| 57 | } |
| 58 | if (type == ServerType.onDevice) { |
| 59 | return 'Local inference'; |
| 60 | } |
| 61 | |
| 62 | final trimmedHost = host.trim(); |
| 63 | final prefix = normalizeServerPathPrefix(pathPrefix); |
| 64 | if (trimmedHost.isEmpty) { |
| 65 | final base = port > 0 ? 'localhost:$port' : 'localhost'; |
| 66 | return prefix.isNotEmpty ? '$base$prefix' : base; |
| 67 | } |
| 68 | |
| 69 | if (_hasExplicitHttpScheme(trimmedHost)) { |
| 70 | final uri = parseServerAddressInput(trimmedHost); |
| 71 | if (uri != null) { |
| 72 | final base = _normalizeNetworkUri( |
| 73 | uri, |
| 74 | port: uri.hasPort || port <= 0 ? null : port, |
| 75 | ).toString(); |
| 76 | return prefix.isNotEmpty ? '$base$prefix' : base; |
| 77 | } |
| 78 | return prefix.isNotEmpty ? '$trimmedHost$prefix' : trimmedHost; |
| 79 | } |
| 80 | |
| 81 | if (trimmedHost.contains(':')) { |
| 82 | return prefix.isNotEmpty ? '$trimmedHost$prefix' : trimmedHost; |
| 83 | } |
| 84 | |
| 85 | final base = port > 0 ? '$trimmedHost:$port' : trimmedHost; |
| 86 | return prefix.isNotEmpty ? '$base$prefix' : base; |
| 87 | } |
| 88 | |
| 89 | String buildServerBaseUrl(String host, int port, ServerType type) { |
| 90 | if (type == ServerType.openRouter) { |
| 91 | return 'https://openrouter.ai/api/v1'; |
| 92 | } |
| 93 | if (type == ServerType.requesty) { |
| 94 | return AppConstants.requestyBaseUrl; |
| 95 | } |
| 96 | if (type == ServerType.ollamaCloud) { |
| 97 | return AppConstants.ollamaCloudBaseUrl; |
| 98 | } |
| 99 | if (type == ServerType.onDevice) { |
| 100 | return 'on-device'; |
| 101 | } |
| 102 | |
| 103 | final trimmedHost = host.trim(); |
| 104 | final uri = parseServerAddressInput(trimmedHost); |
| 105 | if (uri != null) { |
| 106 | return _normalizeNetworkUri( |
| 107 | uri, |
| 108 | port: uri.hasPort || port <= 0 ? null : port, |
| 109 | ).toString(); |
| 110 | } |
| 111 | |
| 112 | if (trimmedHost.isEmpty) { |
| 113 | return ''; |
| 114 | } |
| 115 | |
| 116 | if (port <= 0) { |
| 117 | return 'http://$trimmedHost'; |
| 118 | } |
| 119 | |
| 120 | final protocol = (port == 443) ? 'https' : 'http'; |
| 121 | return '$protocol://$trimmedHost:$port'; |
| 122 | } |
| 123 | |
| 124 | /// Authorization header for [server], or an empty map when no key is set so |
| 125 | /// callers can spread it unconditionally into their headers map. |
| 126 | Map<String, String> buildServerAuthHeaders(Server server) { |
| 127 | if (server.apiKey?.isNotEmpty ?? false) { |
| 128 | return {'Authorization': 'Bearer ${server.apiKey}'}; |
| 129 | } |
| 130 | return const {}; |
| 131 | } |
| 132 | |
| 133 | String normalizeServerPathPrefix(String? raw) { |
| 134 | if (raw == null) return ''; |
| 135 | var value = raw.trim(); |
| 136 | if (value.isEmpty) return ''; |
| 137 | if (!value.startsWith('/')) value = '/$value'; |
| 138 | while (value.endsWith('/') && value.isNotEmpty) { |
| 139 | value = value.substring(0, value.length - 1); |
| 140 | } |
| 141 | return value; |
| 142 | } |
| 143 | |
| 144 | class Server { |
| 145 | final String id; |
| 146 | final String name; |
| 147 | final ServerType type; |
| 148 | final String host; |
| 149 | final int port; |
| 150 | final String? apiKey; |
| 151 | final bool isDefault; |
| 152 | final DateTime createdAt; |
| 153 | final DateTime lastConnectedAt; |
| 154 | final ConnectionStatus status; |
| 155 | final String? iconName; |
| 156 | final String? pathPrefix; |
| 157 | |
| 158 | /// Optional system RAM in GB for model compatibility hints in the browser. |
| 159 | final int? availableRamGb; |
| 160 | |
| 161 | /// Optional GPU VRAM in GB for model compatibility hints in the browser. |
| 162 | final int? availableVramGb; |
| 163 | |
| 164 | Server({ |
| 165 | required this.id, |
| 166 | required this.name, |
| 167 | required this.type, |
| 168 | required this.host, |
| 169 | required this.port, |
| 170 | this.apiKey, |
| 171 | this.isDefault = false, |
| 172 | required this.createdAt, |
| 173 | required this.lastConnectedAt, |
| 174 | this.status = ConnectionStatus.disconnected, |
| 175 | this.iconName, |
| 176 | this.pathPrefix, |
| 177 | this.availableRamGb, |
| 178 | this.availableVramGb, |
| 179 | }); |
| 180 | |
| 181 | String get apiPathPrefix => normalizeServerPathPrefix(pathPrefix); |
| 182 | |
| 183 | String _apiPath(String suffix) { |
| 184 | if (apiPathPrefix.isEmpty) { |
| 185 | return suffix; |
| 186 | } |
| 187 | return '$apiPathPrefix$suffix'; |
| 188 | } |
| 189 | |
| 190 | String get _openAiPrefix { |
| 191 | if (apiPathPrefix.isEmpty) { |
| 192 | return '/v1'; |
| 193 | } |
| 194 | if (apiPathPrefix.endsWith('/v1')) { |
| 195 | return apiPathPrefix; |
| 196 | } |
| 197 | return '$apiPathPrefix/v1'; |
| 198 | } |
| 199 | |
| 200 | String get baseUrl { |
| 201 | return buildServerBaseUrl(host, port, type); |
| 202 | } |
| 203 | |
| 204 | Map<String, String> get authHeaders => buildServerAuthHeaders(this); |
| 205 | |
| 206 | String get chatEndpoint { |
| 207 | switch (type) { |
| 208 | case ServerType.lmStudio: |
| 209 | // LM Studio's native REST endpoint. MCP integrations (both |
| 210 | // ephemeral_mcp and plugin) are only supported here, not on the |
| 211 | // OpenAI-compatible /v1/chat/completions or /v1/responses endpoints. |
| 212 | return '$baseUrl${_apiPath('/api/v1/chat')}'; |
| 213 | case ServerType.openAICompatible: |
| 214 | return '$baseUrl$_openAiPrefix/chat/completions'; |
| 215 | case ServerType.ollama: |
| 216 | case ServerType.ollamaCloud: |
| 217 | return '$baseUrl${_apiPath('/api/chat')}'; |
| 218 | case ServerType.openRouter: |
| 219 | case ServerType.requesty: |
| 220 | return '$baseUrl/chat/completions'; |
| 221 | case ServerType.onDevice: |
| 222 | return ''; |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | String get modelsEndpoint { |
| 227 | switch (type) { |
| 228 | case ServerType.lmStudio: |
| 229 | return '$baseUrl${_apiPath('/api/v1/models')}'; |
| 230 | case ServerType.openAICompatible: |
| 231 | return '$baseUrl$_openAiPrefix/models'; |
| 232 | case ServerType.ollama: |
| 233 | case ServerType.ollamaCloud: |
| 234 | return '$baseUrl${_apiPath('/api/tags')}'; |
| 235 | case ServerType.openRouter: |
| 236 | case ServerType.requesty: |
| 237 | return '$baseUrl/models'; |
| 238 | case ServerType.onDevice: |
| 239 | return ''; |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | String get runningModelsEndpoint { |
| 244 | switch (type) { |
| 245 | case ServerType.lmStudio: |
| 246 | return '$baseUrl${_apiPath('/api/v1/models')}'; |
| 247 | case ServerType.openAICompatible: |
| 248 | return '$baseUrl$_openAiPrefix/models'; |
| 249 | case ServerType.ollama: |
| 250 | return '$baseUrl${_apiPath('/api/ps')}'; |
| 251 | case ServerType.ollamaCloud: |
| 252 | case ServerType.openRouter: |
| 253 | case ServerType.onDevice: |
| 254 | case ServerType.requesty: |
| 255 | return ''; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | String get modelDetailsEndpoint { |
| 260 | switch (type) { |
| 261 | case ServerType.ollama: |
| 262 | case ServerType.ollamaCloud: |
| 263 | return '$baseUrl${_apiPath('/api/show')}'; |
| 264 | case ServerType.lmStudio: |
| 265 | case ServerType.openAICompatible: |
| 266 | case ServerType.openRouter: |
| 267 | case ServerType.onDevice: |
| 268 | case ServerType.requesty: |
| 269 | return ''; |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | String get loadModelEndpoint { |
| 274 | switch (type) { |
| 275 | case ServerType.lmStudio: |
| 276 | return '$baseUrl${_apiPath('/api/v1/models/load')}'; |
| 277 | case ServerType.openAICompatible: |
| 278 | return '$baseUrl$_openAiPrefix/models/load'; |
| 279 | case ServerType.ollama: |
| 280 | return '$baseUrl${_apiPath('/api/generate')}'; |
| 281 | case ServerType.ollamaCloud: |
| 282 | case ServerType.openRouter: |
| 283 | case ServerType.onDevice: |
| 284 | case ServerType.requesty: |
| 285 | return ''; |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | String get unloadModelEndpoint { |
| 290 | switch (type) { |
| 291 | case ServerType.lmStudio: |
| 292 | return '$baseUrl${_apiPath('/api/v1/models/unload')}'; |
| 293 | case ServerType.openAICompatible: |
| 294 | return '$baseUrl$_openAiPrefix/models/unload'; |
| 295 | case ServerType.ollama: |
| 296 | return '$baseUrl${_apiPath('/api/generate')}'; |
| 297 | case ServerType.ollamaCloud: |
| 298 | case ServerType.openRouter: |
| 299 | case ServerType.onDevice: |
| 300 | case ServerType.requesty: |
| 301 | return ''; |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | bool get isOnDevice => type == ServerType.onDevice || id == 'on-device'; |
| 306 | bool get isOllamaCloud => type == ServerType.ollamaCloud; |
| 307 | bool get isOllamaFamily => |
| 308 | type == ServerType.ollama || type == ServerType.ollamaCloud; |
| 309 | |
| 310 | String get displayAddress => |
| 311 | displayServerAddress(host, port, type, pathPrefix); |
| 312 | |
| 313 | Server copyWith({ |
| 314 | String? id, |
| 315 | String? name, |
| 316 | ServerType? type, |
| 317 | String? host, |
| 318 | int? port, |
| 319 | String? apiKey, |
| 320 | bool? isDefault, |
| 321 | DateTime? createdAt, |
| 322 | DateTime? lastConnectedAt, |
| 323 | ConnectionStatus? status, |
| 324 | String? iconName, |
| 325 | String? pathPrefix, |
| 326 | int? availableRamGb, |
| 327 | int? availableVramGb, |
| 328 | bool clearPathPrefix = false, |
| 329 | bool clearAvailableRamGb = false, |
| 330 | bool clearAvailableVramGb = false, |
| 331 | }) { |
| 332 | return Server( |
| 333 | id: id ?? this.id, |
| 334 | name: name ?? this.name, |
| 335 | type: type ?? this.type, |
| 336 | host: host ?? this.host, |
| 337 | port: port ?? this.port, |
| 338 | apiKey: apiKey ?? this.apiKey, |
| 339 | isDefault: isDefault ?? this.isDefault, |
| 340 | createdAt: createdAt ?? this.createdAt, |
| 341 | lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt, |
| 342 | status: status ?? this.status, |
| 343 | iconName: iconName ?? this.iconName, |
| 344 | pathPrefix: clearPathPrefix ? null : (pathPrefix ?? this.pathPrefix), |
| 345 | availableRamGb: clearAvailableRamGb |
| 346 | ? null |
| 347 | : (availableRamGb ?? this.availableRamGb), |
| 348 | availableVramGb: clearAvailableVramGb |
| 349 | ? null |
| 350 | : (availableVramGb ?? this.availableVramGb), |
| 351 | ); |
| 352 | } |
| 353 | } |