main
dart 353 lines 9.84 KB
Raw
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 }