Skip to content

Commit 3350306

Browse files
committed
feat: Implement AI connection testing and response handling for various providers
1 parent f218098 commit 3350306

5 files changed

Lines changed: 513 additions & 8 deletions

File tree

java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/controller/AIConnectionController.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.rostilos.codecrow.webserver.ai.dto.request.CreateAIConnectionRequest;
1111
import org.rostilos.codecrow.webserver.ai.dto.request.UpdateAiConnectionRequest;
1212
import org.rostilos.codecrow.core.dto.ai.AIConnectionDTO;
13+
import org.rostilos.codecrow.webserver.ai.dto.response.AIConnectionTestResponse;
1314
import org.rostilos.codecrow.webserver.ai.service.AIConnectionService;
1415
import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService;
1516
import org.springframework.http.HttpStatus;
@@ -71,6 +72,17 @@ public ResponseEntity<AIConnectionDTO> updateConnection(
7172
}
7273

7374

75+
@PostMapping("/connections/{connectionId}/test")
76+
@HasOwnerOrAdminRights
77+
public ResponseEntity<AIConnectionTestResponse> testConnection(
78+
@PathVariable String workspaceSlug,
79+
@PathVariable Long connectionId
80+
) throws GeneralSecurityException {
81+
Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug);
82+
AIConnectionTestResponse response = aiConnectionService.testAiConnection(workspace.getId(), connectionId);
83+
return new ResponseEntity<>(response, HttpStatus.OK);
84+
}
85+
7486
@DeleteMapping("/connections/{connectionId}")
7587
@HasOwnerOrAdminRights
7688
public ResponseEntity<Void> deleteConnection(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.rostilos.codecrow.webserver.ai.dto.response;
2+
3+
public record AIConnectionTestResponse(
4+
boolean success,
5+
String message,
6+
int statusCode,
7+
long latencyMs
8+
) {
9+
}

java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/service/AIConnectionService.java

Lines changed: 283 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.rostilos.codecrow.webserver.ai.service;
22

3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
35
import jakarta.persistence.EntityManager;
46
import jakarta.persistence.PersistenceContext;
57
import org.rostilos.codecrow.core.model.ai.AIConnection;
@@ -11,11 +13,24 @@
1113
import org.rostilos.codecrow.security.oauth.TokenEncryptionService;
1214
import org.rostilos.codecrow.webserver.ai.dto.request.CreateAIConnectionRequest;
1315
import org.rostilos.codecrow.webserver.ai.dto.request.UpdateAiConnectionRequest;
16+
import org.rostilos.codecrow.webserver.ai.dto.response.AIConnectionTestResponse;
17+
import org.springframework.http.HttpEntity;
18+
import org.springframework.http.HttpHeaders;
19+
import org.springframework.http.HttpMethod;
20+
import org.springframework.http.ResponseEntity;
1421
import org.springframework.stereotype.Service;
1522
import org.springframework.transaction.annotation.Transactional;
23+
import org.springframework.web.client.RestClientResponseException;
24+
import org.springframework.web.client.RestTemplate;
1625

26+
import java.io.IOException;
27+
import java.net.URI;
28+
import java.net.URLEncoder;
29+
import java.nio.charset.StandardCharsets;
1730
import java.security.GeneralSecurityException;
31+
import java.time.Duration;
1832
import java.util.List;
33+
import java.util.Map;
1934
import java.util.NoSuchElementException;
2035

2136
@Service
@@ -26,15 +41,21 @@ public class AIConnectionService {
2641
private final AiConnectionRepository connectionRepository;
2742
private final TokenEncryptionService tokenEncryptionService;
2843
private final WorkspaceRepository workspaceRepository;
44+
private final ObjectMapper objectMapper;
45+
private final RestTemplate restTemplate;
2946

3047
public AIConnectionService(
3148
AiConnectionRepository connectionRepository,
3249
TokenEncryptionService tokenEncryptionService,
33-
WorkspaceRepository workspaceRepository
50+
WorkspaceRepository workspaceRepository,
51+
ObjectMapper objectMapper,
52+
RestTemplate restTemplate
3453
) {
3554
this.connectionRepository = connectionRepository;
3655
this.tokenEncryptionService = tokenEncryptionService;
3756
this.workspaceRepository = workspaceRepository;
57+
this.objectMapper = objectMapper;
58+
this.restTemplate = restTemplate;
3859
}
3960

4061
@Transactional(readOnly = true)
@@ -108,6 +129,267 @@ public void deleteAiConnection(Long workspaceId, Long connectionId) {
108129
connectionRepository.delete(connection);
109130
}
110131

132+
@Transactional(readOnly = true)
133+
public AIConnectionTestResponse testAiConnection(Long workspaceId, Long connectionId) throws GeneralSecurityException {
134+
AIConnection connection = connectionRepository.findByWorkspace_IdAndId(workspaceId, connectionId)
135+
.orElseThrow(() -> new NoSuchElementException("Connection not found"));
136+
String apiKey = tokenEncryptionService.decrypt(connection.getApiKeyEncrypted());
137+
138+
try {
139+
TestRequest request = buildPingRequest(connection, apiKey);
140+
long started = System.nanoTime();
141+
ResponseEntity<String> response = restTemplate.exchange(
142+
URI.create(request.url()),
143+
HttpMethod.POST,
144+
new HttpEntity<>(request.body(), request.headers()),
145+
String.class
146+
);
147+
long latencyMs = Duration.ofNanos(System.nanoTime() - started).toMillis();
148+
149+
if (response.getStatusCode().is2xxSuccessful()) {
150+
if (hasProviderError(response.getBody())) {
151+
return new AIConnectionTestResponse(
152+
false,
153+
"Endpoint responded with an error: " + extractErrorMessage(response.getBody()),
154+
response.getStatusCode().value(),
155+
latencyMs
156+
);
157+
}
158+
return new AIConnectionTestResponse(
159+
true,
160+
"Endpoint responded successfully.",
161+
response.getStatusCode().value(),
162+
latencyMs
163+
);
164+
}
165+
166+
return new AIConnectionTestResponse(
167+
false,
168+
"Endpoint returned HTTP " + response.getStatusCode().value() + ": " + extractErrorMessage(response.getBody()),
169+
response.getStatusCode().value(),
170+
latencyMs
171+
);
172+
} catch (RestClientResponseException e) {
173+
return new AIConnectionTestResponse(
174+
false,
175+
"Endpoint returned HTTP " + e.getStatusCode().value() + ": " + extractErrorMessage(e.getResponseBodyAsString()),
176+
e.getStatusCode().value(),
177+
0
178+
);
179+
} catch (IOException | RuntimeException e) {
180+
return new AIConnectionTestResponse(false, "Connection test failed: " + e.getMessage(), 0, 0);
181+
}
182+
}
183+
184+
private TestRequest buildPingRequest(AIConnection connection, String apiKey) throws JsonProcessingException {
185+
AIProviderKey provider = connection.getProviderKey();
186+
187+
return switch (provider) {
188+
case OPENAI -> buildOpenAiChatRequest(
189+
"https://api.openai.com/v1/chat/completions",
190+
apiKey,
191+
connection.getAiModel(),
192+
Map.of()
193+
);
194+
case OPENROUTER -> buildOpenAiChatRequest(
195+
"https://openrouter.ai/api/v1/chat/completions",
196+
apiKey,
197+
connection.getAiModel(),
198+
Map.of(
199+
"HTTP-Referer", "https://codecrow.cloud",
200+
"X-Title", "CodeCrow AI"
201+
)
202+
);
203+
case OPENAI_COMPATIBLE -> buildOpenAiCompatiblePingRequest(connection, apiKey);
204+
case ANTHROPIC -> buildAnthropicPingRequest(connection, apiKey);
205+
case GOOGLE -> buildGooglePingRequest(connection, apiKey);
206+
};
207+
}
208+
209+
private TestRequest buildOpenAiCompatiblePingRequest(AIConnection connection, String apiKey)
210+
throws JsonProcessingException {
211+
validateBaseUrl(AIProviderKey.OPENAI_COMPATIBLE, connection.getBaseUrl());
212+
String baseUrl = normalizeOpenAiCompatibleBaseUrl(connection.getBaseUrl());
213+
return buildOpenAiChatRequest(
214+
baseUrl + "/chat/completions",
215+
apiKey,
216+
connection.getAiModel(),
217+
Map.of()
218+
);
219+
}
220+
221+
private TestRequest buildOpenAiChatRequest(
222+
String url,
223+
String apiKey,
224+
String model,
225+
Map<String, String> extraHeaders
226+
) throws JsonProcessingException {
227+
Map<String, Object> payload = Map.of(
228+
"model", model,
229+
"messages", List.of(Map.of(
230+
"role", "user",
231+
"content", "ping"
232+
))
233+
);
234+
235+
HttpHeaders headers = new HttpHeaders();
236+
headers.setBearerAuth(apiKey);
237+
extraHeaders.forEach(headers::set);
238+
return jsonPost(url, headers, payload);
239+
}
240+
241+
private TestRequest buildAnthropicPingRequest(AIConnection connection, String apiKey)
242+
throws JsonProcessingException {
243+
Map<String, Object> payload = Map.of(
244+
"model", connection.getAiModel(),
245+
"max_tokens", 8,
246+
"messages", List.of(Map.of(
247+
"role", "user",
248+
"content", "ping"
249+
))
250+
);
251+
252+
HttpHeaders headers = new HttpHeaders();
253+
headers.set("x-api-key", apiKey);
254+
headers.set("anthropic-version", "2023-06-01");
255+
return jsonPost("https://api.anthropic.com/v1/messages", headers, payload);
256+
}
257+
258+
private TestRequest buildGooglePingRequest(AIConnection connection, String apiKey)
259+
throws JsonProcessingException {
260+
String model = connection.getAiModel();
261+
if (model.startsWith("models/")) {
262+
model = model.substring("models/".length());
263+
}
264+
265+
String encodedModel = URLEncoder.encode(model, StandardCharsets.UTF_8);
266+
String encodedKey = URLEncoder.encode(apiKey, StandardCharsets.UTF_8);
267+
String url = "https://generativelanguage.googleapis.com/v1beta/models/"
268+
+ encodedModel + ":generateContent?key=" + encodedKey;
269+
270+
Map<String, Object> payload = Map.of(
271+
"contents", List.of(Map.of(
272+
"role", "user",
273+
"parts", List.of(Map.of("text", "ping"))
274+
)),
275+
"generationConfig", Map.of(
276+
"maxOutputTokens", 8,
277+
"temperature", 0
278+
)
279+
);
280+
281+
return jsonPost(url, new HttpHeaders(), payload);
282+
}
283+
284+
private TestRequest jsonPost(String url, HttpHeaders headers, Map<String, Object> payload)
285+
throws JsonProcessingException {
286+
headers.set("Content-Type", "application/json");
287+
headers.set("Accept", "application/json");
288+
return new TestRequest(url, headers, objectMapper.writeValueAsString(payload));
289+
}
290+
291+
private String normalizeOpenAiCompatibleBaseUrl(String aiBaseUrl) {
292+
String baseUrl = trimOpenAiEndpointSuffix(aiBaseUrl.strip().replaceAll("/+$", ""));
293+
URI uri = URI.create(baseUrl);
294+
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase();
295+
String path = uri.getPath() == null ? "" : uri.getPath();
296+
297+
if ("api.cloudflare.com".equals(host) || host.endsWith(".ai.cloudflare.com")) {
298+
if ("api.cloudflare.com".equals(host) && path.endsWith("/ai")) {
299+
return baseUrl + "/v1";
300+
}
301+
return baseUrl;
302+
}
303+
304+
if (!baseUrl.endsWith("/v1")) {
305+
return baseUrl + "/v1";
306+
}
307+
return baseUrl;
308+
}
309+
310+
private String trimOpenAiEndpointSuffix(String baseUrl) {
311+
List<String> suffixes = List.of(
312+
"/chat/completions",
313+
"/completions",
314+
"/embeddings",
315+
"/responses"
316+
);
317+
for (String suffix : suffixes) {
318+
if (baseUrl.endsWith(suffix)) {
319+
return baseUrl.substring(0, baseUrl.length() - suffix.length());
320+
}
321+
}
322+
return baseUrl;
323+
}
324+
325+
@SuppressWarnings("unchecked")
326+
private boolean hasProviderError(String body) {
327+
try {
328+
Object parsed = objectMapper.readValue(body, Object.class);
329+
if (parsed instanceof Map<?, ?> map) {
330+
Object success = map.get("success");
331+
Object error = map.get("error");
332+
Object errors = map.get("errors");
333+
return Boolean.FALSE.equals(success) || error != null || hasNonEmptyList(errors);
334+
}
335+
} catch (Exception ignored) {
336+
return false;
337+
}
338+
return false;
339+
}
340+
341+
private boolean hasNonEmptyList(Object value) {
342+
return value instanceof List<?> list && !list.isEmpty();
343+
}
344+
345+
private String extractErrorMessage(String body) {
346+
if (body == null || body.isBlank()) {
347+
return "No response body";
348+
}
349+
350+
try {
351+
Object parsed = objectMapper.readValue(body, Object.class);
352+
if (parsed instanceof Map<?, ?> map) {
353+
Object error = map.get("error");
354+
if (error instanceof Map<?, ?> errorMap && errorMap.get("message") != null) {
355+
return truncate(String.valueOf(errorMap.get("message")));
356+
}
357+
if (error instanceof String errorString) {
358+
return truncate(errorString);
359+
}
360+
361+
Object errors = map.get("errors");
362+
if (errors instanceof List<?> list && !list.isEmpty()) {
363+
Object first = list.get(0);
364+
if (first instanceof Map<?, ?> firstError && firstError.get("message") != null) {
365+
return truncate(String.valueOf(firstError.get("message")));
366+
}
367+
return truncate(String.valueOf(first));
368+
}
369+
370+
Object message = map.get("message");
371+
if (message != null) {
372+
return truncate(String.valueOf(message));
373+
}
374+
}
375+
} catch (Exception ignored) {
376+
// Fall back to raw body below.
377+
}
378+
379+
return truncate(body);
380+
}
381+
382+
private String truncate(String value) {
383+
String sanitized = value.replaceAll("[\\r\\n\\t]+", " ").strip();
384+
if (sanitized.length() <= 500) {
385+
return sanitized;
386+
}
387+
return sanitized.substring(0, 500) + "...";
388+
}
389+
390+
private record TestRequest(String url, HttpHeaders headers, String body) {
391+
}
392+
111393
/**
112394
* Validates baseUrl for OPENAI_COMPATIBLE connections.
113395
* Enforces HTTPS, valid URL format, and rejects private/reserved IPs (SSRF protection).

0 commit comments

Comments
 (0)