@tduncan / Cortana / commits / d5a2a2b

feat: add path prefix support for server connections and improve host parsing in UI

abdulmominsakib committed Sep 11, 2026 at 13:19 UTC d5a2a2b9b2b70512eaed1a6567551f665e6c873d
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 }