5 files changed
+456
-58
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+69
-15
@@ -81,10 +81,49 @@ class _OnboardingServerSetupScreenState
81
_hostController = TextEditingController(text: defaultHost);
82
_portController = TextEditingController(text: defaultPort);
83
_apiKeyController = TextEditingController();
84
+ _lastHostScheme = isHttpsAddressInput(defaultHost) ? 'https' : 'http';
85
+ _hostController.addListener(_onHostChanged);
86
+ }
87
+
88
+ String _lastHostScheme = '';
89
+
90
+ int _defaultPortForType(ServerType type) {
91
+ return switch (type) {
92
+ ServerType.lmStudio => AppConstants.lmStudioDefaultPort,
93
+ ServerType.openAICompatible => AppConstants.openAICompatibleDefaultPort,
94
+ ServerType.ollama => AppConstants.ollamaDefaultPort,
95
+ ServerType.ollamaCloud => AppConstants.ollamaCloudDefaultPort,
96
+ ServerType.openRouter => 443,
97
+ ServerType.onDevice => 0,
98
+ };
99
+ }
100
+
101
+ void _onHostChanged() {
102
+ final text = _hostController.text.trim();
103
+ final parsed = parseServerAddressInput(text);
104
+ if (parsed == null) return;
105
+
106
+ if (parsed.hasPort) {
107
+ _portController.text = parsed.port.toString();
108
+ } else if (parsed.scheme == 'https' && _lastHostScheme != 'https') {
109
+ final currentPort = int.tryParse(_portController.text.trim());
110
+ if (currentPort == _defaultPortForType(widget.selectedType)) {
111
+ _portController.text = '443';
112
+ }
113
+ } else if (parsed.scheme == 'http' && _lastHostScheme == 'https') {
114
+ final currentPort = int.tryParse(_portController.text.trim());
115
+ if (currentPort == 443) {
116
+ _portController.text = _defaultPortForType(
117
+ widget.selectedType,
118
+ ).toString();
119
+ }
120
+ }
121
+ _lastHostScheme = parsed.scheme;
122
}
123
124
@override
125
void dispose() {
126
+ _hostController.removeListener(_onHostChanged);
127
_nameController.dispose();
128
_hostController.dispose();
129
_portController.dispose();
@@ -94,20 +133,33 @@ class _OnboardingServerSetupScreenState
133
134
Server _buildServer() {
135
final requiresCloudConfig = widget.selectedType == ServerType.ollamaCloud;
97
- final host = requiresCloudConfig
98
- ? AppConstants.ollamaCloudBaseUrl
99
- : _hostController.text.trim();
100
- final defaultPort = switch (widget.selectedType) {
101
- ServerType.lmStudio => AppConstants.lmStudioDefaultPort,
102
- ServerType.openAICompatible => AppConstants.openAICompatibleDefaultPort,
103
- ServerType.ollama => AppConstants.ollamaDefaultPort,
104
- ServerType.ollamaCloud => AppConstants.ollamaCloudDefaultPort,
105
- ServerType.openRouter => 443,
106
- ServerType.onDevice => 0,
107
- };
108
- final port = requiresCloudConfig
109
- ? AppConstants.ollamaCloudDefaultPort
110
- : (int.tryParse(_portController.text.trim()) ?? defaultPort);
136
+ final defaultPort = _defaultPortForType(widget.selectedType);
137
+ String host;
138
+ int port;
139
+ String? pathPrefix;
140
+
141
+ if (requiresCloudConfig) {
142
+ host = AppConstants.ollamaCloudBaseUrl;
143
+ port = AppConstants.ollamaCloudDefaultPort;
144
+ } else {
145
+ final rawHost = _hostController.text.trim();
146
+ final parsed = parseServerAddressInput(rawHost);
147
+ if (parsed != null) {
148
+ final hasExplicitPort = parsed.hasPort;
149
+ host =
150
+ '${parsed.scheme}://${parsed.host}${hasExplicitPort ? ':${parsed.port}' : ''}';
151
+ final schemeDefaultPort = parsed.scheme == 'https' ? 443 : defaultPort;
152
+ port = hasExplicitPort
153
+ ? parsed.port
154
+ : (int.tryParse(_portController.text.trim()) ?? schemeDefaultPort);
155
+ final prefix = extractPathPrefix(parsed);
156
+ pathPrefix = prefix.isNotEmpty ? prefix : null;
157
+ } else {
158
+ host = rawHost;
159
+ port = int.tryParse(_portController.text.trim()) ?? defaultPort;
160
+ pathPrefix = null;
161
+ }
162
+ }
163
164
return Server(
165
id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -124,6 +176,7 @@ class _OnboardingServerSetupScreenState
176
createdAt: DateTime.now(),
177
lastConnectedAt: DateTime.now(),
178
status: ConnectionStatus.disconnected,
179
+ pathPrefix: pathPrefix,
180
);
181
}
182
@@ -137,7 +190,8 @@ class _OnboardingServerSetupScreenState
190
if (value == null || value.trim().isEmpty) {
191
return l10n?.host_required ?? 'Host is required';
192
}
140
- if (parseServerAddressInput(value) == null) {
193
+ final parsed = parseServerAddressInput(value);
194
+ if (parsed == null) {
195
return l10n?.host_valid ?? 'Enter a valid host';
196
}
197
return null;
lib/features/servers/data/models/server.dart
+46
-22
@@ -16,10 +16,17 @@ Uri? parseServerAddressInput(String input) {
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.path.isNotEmpty && uri.path != '/') 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,7 +40,12 @@ Uri _normalizeNetworkUri(Uri uri, {int? port}) {
40
);
41
}
42
36
-String displayServerAddress(String host, int port, ServerType type) {
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
}
@@ -45,26 +57,30 @@ String displayServerAddress(String host, int port, ServerType type) {
57
}
58
59
final trimmedHost = host.trim();
60
+ final prefix = normalizeServerPathPrefix(pathPrefix);
61
if (trimmedHost.isEmpty) {
49
- return port > 0 ? 'localhost:$port' : 'localhost';
62
+ final base = port > 0 ? 'localhost:$port' : 'localhost';
63
+ return prefix.isNotEmpty ? '$base$prefix' : base;
64
}
65
66
if (_hasExplicitHttpScheme(trimmedHost)) {
67
final uri = parseServerAddressInput(trimmedHost);
68
if (uri != null) {
55
- return _normalizeNetworkUri(
69
+ final base = _normalizeNetworkUri(
70
uri,
71
port: uri.hasPort || port <= 0 ? null : port,
72
).toString();
73
+ return prefix.isNotEmpty ? '$base$prefix' : base;
74
}
60
- return trimmedHost;
75
+ return prefix.isNotEmpty ? '$trimmedHost$prefix' : trimmedHost;
76
}
77
78
if (trimmedHost.contains(':')) {
64
- return trimmedHost;
79
+ return prefix.isNotEmpty ? '$trimmedHost$prefix' : trimmedHost;
80
}
81
67
- return port > 0 ? '$trimmedHost:$port' : trimmedHost;
82
+ final base = port > 0 ? '$trimmedHost:$port' : trimmedHost;
83
+ return prefix.isNotEmpty ? '$base$prefix' : base;
84
}
85
86
String buildServerBaseUrl(String host, int port, ServerType type) {
@@ -113,7 +129,7 @@ String normalizeServerPathPrefix(String? raw) {
129
var value = raw.trim();
130
if (value.isEmpty) return '';
131
if (!value.startsWith('/')) value = '/$value';
116
- while (value.endsWith('/') && value.length > 1) {
132
+ while (value.endsWith('/') && value.isNotEmpty) {
133
value = value.substring(0, value.length - 1);
134
}
135
return value;
@@ -162,12 +178,19 @@ class Server {
178
if (apiPathPrefix.isEmpty) {
179
return suffix;
180
}
165
- if (type != ServerType.ollama && type != ServerType.ollamaCloud) {
166
- return suffix;
167
- }
181
return '$apiPathPrefix$suffix';
182
}
183
184
+ String get _openAiPrefix {
185
+ if (apiPathPrefix.isEmpty) {
186
+ return '/v1';
187
+ }
188
+ if (apiPathPrefix.endsWith('/v1')) {
189
+ return apiPathPrefix;
190
+ }
191
+ return '$apiPathPrefix/v1';
192
+ }
193
+
194
String get baseUrl {
195
return buildServerBaseUrl(host, port, type);
196
}
@@ -180,9 +203,9 @@ class Server {
203
// LM Studio's native REST endpoint. MCP integrations (both
204
// ephemeral_mcp and plugin) are only supported here, not on the
205
// OpenAI-compatible /v1/chat/completions or /v1/responses endpoints.
183
- return '$baseUrl/api/v1/chat';
206
+ return '$baseUrl${_apiPath('/api/v1/chat')}';
207
case ServerType.openAICompatible:
185
- return '$baseUrl/v1/chat/completions';
208
+ return '$baseUrl$_openAiPrefix/chat/completions';
209
case ServerType.ollama:
210
case ServerType.ollamaCloud:
211
return '$baseUrl${_apiPath('/api/chat')}';
@@ -196,9 +219,9 @@ class Server {
219
String get modelsEndpoint {
220
switch (type) {
221
case ServerType.lmStudio:
199
- return '$baseUrl/api/v1/models';
222
+ return '$baseUrl${_apiPath('/api/v1/models')}';
223
case ServerType.openAICompatible:
201
- return '$baseUrl/v1/models';
224
+ return '$baseUrl$_openAiPrefix/models';
225
case ServerType.ollama:
226
case ServerType.ollamaCloud:
227
return '$baseUrl${_apiPath('/api/tags')}';
@@ -212,9 +235,9 @@ class Server {
235
String get runningModelsEndpoint {
236
switch (type) {
237
case ServerType.lmStudio:
215
- return '$baseUrl/api/v1/models';
238
+ return '$baseUrl${_apiPath('/api/v1/models')}';
239
case ServerType.openAICompatible:
217
- return '$baseUrl/v1/models';
240
+ return '$baseUrl$_openAiPrefix/models';
241
case ServerType.ollama:
242
return '$baseUrl${_apiPath('/api/ps')}';
243
case ServerType.ollamaCloud:
@@ -240,9 +263,9 @@ class Server {
263
String get loadModelEndpoint {
264
switch (type) {
265
case ServerType.lmStudio:
243
- return '$baseUrl/api/v1/models/load';
266
+ return '$baseUrl${_apiPath('/api/v1/models/load')}';
267
case ServerType.openAICompatible:
245
- return '$baseUrl/v1/models/load';
268
+ return '$baseUrl$_openAiPrefix/models/load';
269
case ServerType.ollama:
270
return '$baseUrl${_apiPath('/api/generate')}';
271
case ServerType.ollamaCloud:
@@ -255,9 +278,9 @@ class Server {
278
String get unloadModelEndpoint {
279
switch (type) {
280
case ServerType.lmStudio:
258
- return '$baseUrl/api/v1/models/unload';
281
+ return '$baseUrl${_apiPath('/api/v1/models/unload')}';
282
case ServerType.openAICompatible:
260
- return '$baseUrl/v1/models/unload';
283
+ return '$baseUrl$_openAiPrefix/models/unload';
284
case ServerType.ollama:
285
return '$baseUrl${_apiPath('/api/generate')}';
286
case ServerType.ollamaCloud:
@@ -272,7 +295,8 @@ class Server {
295
bool get isOllamaFamily =>
296
type == ServerType.ollama || type == ServerType.ollamaCloud;
297
275
- String get displayAddress => displayServerAddress(host, port, type);
298
+ String get displayAddress =>
299
+ displayServerAddress(host, port, type, pathPrefix);
300
301
Server copyWith({
302
String? id,
lib/features/servers/data/server_api_service.dart
+7
-6
@@ -21,14 +21,15 @@ class ServerApiService {
21
endpointsToTry.add(server.modelsEndpoint);
22
}
23
24
+ final prefix = server.apiPathPrefix;
25
if (server.type == ServerType.lmStudio) {
25
- endpointsToTry.add('${server.baseUrl}/v1/models');
26
- endpointsToTry.add('${server.baseUrl}/api/v0/models');
26
+ endpointsToTry.add('${server.baseUrl}$prefix/v1/models');
27
+ endpointsToTry.add('${server.baseUrl}$prefix/api/v0/models');
28
} else if (server.type == ServerType.openAICompatible) {
28
- endpointsToTry.add('${server.baseUrl}/models');
29
+ endpointsToTry.add('${server.baseUrl}$prefix/models');
30
} else if (server.type == ServerType.ollama) {
30
- endpointsToTry.add('${server.baseUrl}/api/tags');
31
- endpointsToTry.add('${server.baseUrl}/api/version');
31
+ endpointsToTry.add('${server.baseUrl}$prefix/api/tags');
32
+ endpointsToTry.add('${server.baseUrl}$prefix/api/version');
33
}
34
35
for (final endpoint in endpointsToTry.toSet()) {
@@ -77,7 +78,7 @@ class ServerApiService {
78
if (e.response?.statusCode == 404 &&
79
server.type == ServerType.lmStudio) {
80
response = await _dio.get(
80
- '${server.baseUrl}/v1/models',
81
+ '${server.baseUrl}${server.apiPathPrefix}/v1/models',
82
options: Options(headers: buildServerAuthHeaders(server)),
83
);
84
} else {
lib/features/servers/views/add_server_screen.dart
+73
-15
@@ -47,6 +47,40 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
47
_selectedType == ServerType.openRouter ||
48
_selectedType == ServerType.ollamaCloud;
49
50
+ String _lastHostScheme = '';
51
+
52
+ int _defaultPortForType(ServerType type) {
53
+ return switch (type) {
54
+ ServerType.lmStudio => AppConstants.lmStudioDefaultPort,
55
+ ServerType.openAICompatible => AppConstants.openAICompatibleDefaultPort,
56
+ ServerType.ollama => AppConstants.ollamaDefaultPort,
57
+ ServerType.ollamaCloud => AppConstants.ollamaCloudDefaultPort,
58
+ ServerType.openRouter => 443,
59
+ ServerType.onDevice => 0,
60
+ };
61
+ }
62
+
63
+ void _onHostChanged() {
64
+ final text = _hostController.text.trim();
65
+ final parsed = parseServerAddressInput(text);
66
+ if (parsed == null) return;
67
+
68
+ if (parsed.hasPort) {
69
+ _portController.text = parsed.port.toString();
70
+ } else if (parsed.scheme == 'https' && _lastHostScheme != 'https') {
71
+ final currentPort = int.tryParse(_portController.text.trim());
72
+ if (currentPort == _defaultPortForType(_selectedType)) {
73
+ _portController.text = '443';
74
+ }
75
+ } else if (parsed.scheme == 'http' && _lastHostScheme == 'https') {
76
+ final currentPort = int.tryParse(_portController.text.trim());
77
+ if (currentPort == 443) {
78
+ _portController.text = _defaultPortForType(_selectedType).toString();
79
+ }
80
+ }
81
+ _lastHostScheme = parsed.scheme;
82
+ }
83
+
84
@override
85
void initState() {
86
super.initState();
@@ -54,7 +88,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
88
_selectedType = server?.type ?? ServerType.lmStudio;
89
_selectedIconName = server?.iconName;
90
_nameController = TextEditingController(text: server?.name ?? '');
57
- _hostController = TextEditingController(text: server?.host ?? '');
91
+ final initialHost = server != null
92
+ ? (server.pathPrefix != null && server.pathPrefix!.isNotEmpty
93
+ ? '${server.host}${server.pathPrefix}'
94
+ : server.host)
95
+ : '';
96
+ _hostController = TextEditingController(text: initialHost);
97
_portController = TextEditingController(
98
text:
99
server?.port.toString() ??
@@ -67,10 +106,13 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
106
_vramGbController = TextEditingController(
107
text: server?.availableVramGb?.toString() ?? '',
108
);
109
+ _lastHostScheme = isHttpsAddressInput(initialHost) ? 'https' : 'http';
110
+ _hostController.addListener(_onHostChanged);
111
}
112
113
@override
114
void dispose() {
115
+ _hostController.removeListener(_onHostChanged);
116
_scrollController.dispose();
117
_nameController.dispose();
118
_hostController.dispose();
@@ -86,11 +128,14 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
128
_selectedType = type;
129
_testResult = null;
130
131
+ final isHttps = isHttpsAddressInput(_hostController.text);
132
if (type == ServerType.openRouter) {
133
_portController.text = '443';
134
} else if (type == ServerType.ollamaCloud) {
135
_portController.text = AppConstants.ollamaCloudDefaultPort.toString();
136
_hostController.text = AppConstants.ollamaCloudBaseUrl;
137
+ } else if (isHttps) {
138
+ _portController.text = '443';
139
} else if (type == ServerType.lmStudio) {
140
_portController.text = AppConstants.lmStudioDefaultPort.toString();
141
} else if (type == ServerType.openAICompatible) {
@@ -167,20 +212,32 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
212
Server _buildServer() {
213
final rawHost = _hostController.text.trim();
214
final rawPort = _portController.text.trim();
170
- final host = _selectedType == ServerType.ollamaCloud
171
- ? AppConstants.ollamaCloudBaseUrl
172
- : rawHost;
173
- final defaultPort = switch (_selectedType) {
174
- ServerType.lmStudio => AppConstants.lmStudioDefaultPort,
175
- ServerType.openAICompatible => AppConstants.openAICompatibleDefaultPort,
176
- ServerType.ollama => AppConstants.ollamaDefaultPort,
177
- ServerType.ollamaCloud => AppConstants.ollamaCloudDefaultPort,
178
- ServerType.openRouter => 443,
179
- ServerType.onDevice => 0,
180
- };
181
- final port = _selectedType == ServerType.ollamaCloud
182
- ? AppConstants.ollamaCloudDefaultPort
183
- : (int.tryParse(rawPort) ?? defaultPort);
215
+ final defaultPort = _defaultPortForType(_selectedType);
216
+ String host;
217
+ int port;
218
+ String? pathPrefix;
219
+
220
+ if (_selectedType == ServerType.ollamaCloud) {
221
+ host = AppConstants.ollamaCloudBaseUrl;
222
+ port = AppConstants.ollamaCloudDefaultPort;
223
+ } else {
224
+ final parsed = parseServerAddressInput(rawHost);
225
+ if (parsed != null) {
226
+ final hasExplicitPort = parsed.hasPort;
227
+ host =
228
+ '${parsed.scheme}://${parsed.host}${hasExplicitPort ? ':${parsed.port}' : ''}';
229
+ final schemeDefaultPort = parsed.scheme == 'https' ? 443 : defaultPort;
230
+ port = hasExplicitPort
231
+ ? parsed.port
232
+ : (int.tryParse(rawPort) ?? schemeDefaultPort);
233
+ final prefix = extractPathPrefix(parsed);
234
+ pathPrefix = prefix.isNotEmpty ? prefix : null;
235
+ } else {
236
+ host = rawHost;
237
+ port = int.tryParse(rawPort) ?? defaultPort;
238
+ pathPrefix = null;
239
+ }
240
+ }
241
242
return Server(
243
id:
@@ -200,6 +257,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
257
lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
258
status: widget.editServer?.status ?? ConnectionStatus.disconnected,
259
iconName: _selectedIconName,
260
+ pathPrefix: pathPrefix,
261
availableRamGb: _selectedType == ServerType.lmStudio
262
? _parseOptionalGb(_ramGbController.text)
263
: widget.editServer?.availableRamGb,
test/features/servers/server_address_test.dart
+261
@@ -62,5 +62,266 @@ void main() {
62
expect(server.baseUrl, 'http://localhost:8080');
63
expect(server.displayAddress, 'localhost:8080');
64
});
65
+
66
+ test('accepts a bare subdomain with no path', () {
67
+ final server = Server(
68
+ id: '1',
69
+ name: 'Remote Ollama',
70
+ type: ServerType.ollama,
71
+ host: 'ollama.example.com',
72
+ port: 11434,
73
+ createdAt: DateTime(2026, 1, 1),
74
+ lastConnectedAt: DateTime(2026, 1, 1),
75
+ );
76
+
77
+ expect(server.baseUrl, 'http://ollama.example.com:11434');
78
+ expect(server.pathPrefix, isNull);
79
+ });
80
+
81
+ test('accepts https subdomain with no path', () {
82
+ final server = Server(
83
+ id: '1',
84
+ name: 'Secure Ollama',
85
+ type: ServerType.ollama,
86
+ host: 'https://ollama.example.com',
87
+ port: 11434,
88
+ createdAt: DateTime(2026, 1, 1),
89
+ lastConnectedAt: DateTime(2026, 1, 1),
90
+ );
91
+
92
+ expect(server.baseUrl, 'https://ollama.example.com:11434');
93
+ expect(server.pathPrefix, isNull);
94
+ });
95
+
96
+ test(
97
+ 'Ollama server with explicit pathPrefix produces correct endpoints',
98
+ () {
99
+ final server = Server(
100
+ id: '1',
101
+ name: 'Reverse Proxy',
102
+ type: ServerType.ollama,
103
+ host: 'https://ai.example.com',
104
+ port: 11434,
105
+ pathPrefix: '/ollama',
106
+ createdAt: DateTime(2026, 1, 1),
107
+ lastConnectedAt: DateTime(2026, 1, 1),
108
+ );
109
+
110
+ expect(server.baseUrl, 'https://ai.example.com:11434');
111
+ expect(server.pathPrefix, '/ollama');
112
+ expect(
113
+ server.modelsEndpoint,
114
+ 'https://ai.example.com:11434/ollama/api/tags',
115
+ );
116
+ expect(
117
+ server.chatEndpoint,
118
+ 'https://ai.example.com:11434/ollama/api/chat',
119
+ );
120
+ },
121
+ );
122
+
123
+ test('trailing slash in pathPrefix is normalized via apiPathPrefix', () {
124
+ final server = Server(
125
+ id: '1',
126
+ name: 'Trailing slash',
127
+ type: ServerType.ollama,
128
+ host: 'https://ai.example.com',
129
+ port: 11434,
130
+ pathPrefix: '/ollama/',
131
+ createdAt: DateTime(2026, 1, 1),
132
+ lastConnectedAt: DateTime(2026, 1, 1),
133
+ );
134
+
135
+ expect(server.apiPathPrefix, '/ollama');
136
+ expect(
137
+ server.modelsEndpoint,
138
+ 'https://ai.example.com:11434/ollama/api/tags',
139
+ );
140
+ });
141
+
142
+ test('root path becomes empty pathPrefix', () {
143
+ final server = Server(
144
+ id: '1',
145
+ name: 'Root path',
146
+ type: ServerType.ollama,
147
+ host: 'https://ai.example.com',
148
+ port: 11434,
149
+ pathPrefix: '',
150
+ createdAt: DateTime(2026, 1, 1),
151
+ lastConnectedAt: DateTime(2026, 1, 1),
152
+ );
153
+
154
+ expect(server.pathPrefix, '');
155
+ expect(server.baseUrl, 'https://ai.example.com:11434');
156
+ expect(server.modelsEndpoint, 'https://ai.example.com:11434/api/tags');
157
+ });
158
+
159
+ test('explicit port in host overrides default port', () {
160
+ final server = Server(
161
+ id: '1',
162
+ name: 'Custom port',
163
+ type: ServerType.ollama,
164
+ host: 'https://ai.example.com:8443',
165
+ port: 11434,
166
+ pathPrefix: '/ollama',
167
+ createdAt: DateTime(2026, 1, 1),
168
+ lastConnectedAt: DateTime(2026, 1, 1),
169
+ );
170
+
171
+ expect(server.baseUrl, 'https://ai.example.com:8443');
172
+ expect(server.pathPrefix, '/ollama');
173
+ });
174
+
175
+ test('OpenAI-compatible with pathPrefix produces correct endpoints', () {
176
+ final server = Server(
177
+ id: '1',
178
+ name: 'OpenAI proxy',
179
+ type: ServerType.openAICompatible,
180
+ host: 'https://ai.example.com',
181
+ port: 8080,
182
+ pathPrefix: '/openai',
183
+ createdAt: DateTime(2026, 1, 1),
184
+ lastConnectedAt: DateTime(2026, 1, 1),
185
+ );
186
+
187
+ expect(server.baseUrl, 'https://ai.example.com:8080');
188
+ expect(server.pathPrefix, '/openai');
189
+ expect(
190
+ server.modelsEndpoint,
191
+ 'https://ai.example.com:8080/openai/v1/models',
192
+ );
193
+ expect(
194
+ server.chatEndpoint,
195
+ 'https://ai.example.com:8080/openai/v1/chat/completions',
196
+ );
197
+ expect(server.displayAddress, 'https://ai.example.com:8080/openai');
198
+ });
199
+
200
+ test('OpenAI-compatible with /v1 pathPrefix avoids double /v1', () {
201
+ final server = Server(
202
+ id: '1',
203
+ name: 'OpenAI direct v1',
204
+ type: ServerType.openAICompatible,
205
+ host: 'https://ai.example.com',
206
+ port: 8080,
207
+ pathPrefix: '/v1',
208
+ createdAt: DateTime(2026, 1, 1),
209
+ lastConnectedAt: DateTime(2026, 1, 1),
210
+ );
211
+
212
+ expect(server.modelsEndpoint, 'https://ai.example.com:8080/v1/models');
213
+ expect(
214
+ server.chatEndpoint,
215
+ 'https://ai.example.com:8080/v1/chat/completions',
216
+ );
217
+ });
218
+
219
+ test(
220
+ 'OpenAI-compatible with nested /proxy/v1 pathPrefix avoids double /v1',
221
+ () {
222
+ final server = Server(
223
+ id: '1',
224
+ name: 'OpenAI nested proxy',
225
+ type: ServerType.openAICompatible,
226
+ host: 'https://ai.example.com',
227
+ port: 8080,
228
+ pathPrefix: '/proxy/v1',
229
+ createdAt: DateTime(2026, 1, 1),
230
+ lastConnectedAt: DateTime(2026, 1, 1),
231
+ );
232
+
233
+ expect(
234
+ server.modelsEndpoint,
235
+ 'https://ai.example.com:8080/proxy/v1/models',
236
+ );
237
+ expect(
238
+ server.chatEndpoint,
239
+ 'https://ai.example.com:8080/proxy/v1/chat/completions',
240
+ );
241
+ },
242
+ );
243
+
244
+ test('LM Studio with pathPrefix produces correct endpoints', () {
245
+ final server = Server(
246
+ id: '1',
247
+ name: 'LM Studio proxy',
248
+ type: ServerType.lmStudio,
249
+ host: 'https://ai.example.com',
250
+ port: 1234,
251
+ pathPrefix: '/lmstudio',
252
+ createdAt: DateTime(2026, 1, 1),
253
+ lastConnectedAt: DateTime(2026, 1, 1),
254
+ );
255
+
256
+ expect(
257
+ server.modelsEndpoint,
258
+ 'https://ai.example.com:1234/lmstudio/api/v1/models',
259
+ );
260
+ expect(
261
+ server.chatEndpoint,
262
+ 'https://ai.example.com:1234/lmstudio/api/v1/chat',
263
+ );
264
+ expect(server.displayAddress, 'https://ai.example.com:1234/lmstudio');
265
+ });
266
+
267
+ test('rejects URL with query string', () {
268
+ expect(parseServerAddressInput('https://ai.example.com?foo=bar'), isNull);
269
+ });
270
+
271
+ test('rejects URL with fragment', () {
272
+ expect(parseServerAddressInput('https://ai.example.com#frag'), isNull);
273
+ });
274
+
275
+ test('normalizeServerPathPrefix converts root slash to empty', () {
276
+ expect(normalizeServerPathPrefix('/'), '');
277
+ });
278
+
279
+ test('normalizeServerPathPrefix strips trailing slash', () {
280
+ expect(normalizeServerPathPrefix('/ollama/'), '/ollama');
281
+ });
282
+
283
+ test('extractPathPrefix returns empty for no path', () {
284
+ final uri = Uri.parse('https://example.com');
285
+ expect(extractPathPrefix(uri), '');
286
+ });
287
+
288
+ test('extractPathPrefix returns empty for root slash', () {
289
+ final uri = Uri.parse('https://example.com/');
290
+ expect(extractPathPrefix(uri), '');
291
+ });
292
+
293
+ test('extractPathPrefix returns path for valid prefix', () {
294
+ final uri = Uri.parse('https://example.com/ollama');
295
+ expect(extractPathPrefix(uri), '/ollama');
296
+ });
297
+
298
+ test('local IP still works as before', () {
299
+ final server = Server(
300
+ id: '1',
301
+ name: 'Local',
302
+ type: ServerType.openAICompatible,
303
+ host: '192.168.1.100',
304
+ port: 8080,
305
+ createdAt: DateTime(2026, 1, 1),
306
+ lastConnectedAt: DateTime(2026, 1, 1),
307
+ );
308
+
309
+ expect(server.baseUrl, 'http://192.168.1.100:8080');
310
+ expect(server.pathPrefix, isNull);
311
+ });
312
+
313
+ test('existing bare host without port still works', () {
314
+ final server = Server(
315
+ id: '1',
316
+ name: 'Local',
317
+ type: ServerType.openAICompatible,
318
+ host: 'localhost',
319
+ port: 8080,
320
+ createdAt: DateTime(2026, 1, 1),
321
+ lastConnectedAt: DateTime(2026, 1, 1),
322
+ );
323
+
324
+ expect(server.baseUrl, 'http://localhost:8080');
325
+ });
326
});
327
}