11package org .rostilos .codecrow .webserver .ai .service ;
22
3+ import com .fasterxml .jackson .core .JsonProcessingException ;
4+ import com .fasterxml .jackson .databind .ObjectMapper ;
35import jakarta .persistence .EntityManager ;
46import jakarta .persistence .PersistenceContext ;
57import org .rostilos .codecrow .core .model .ai .AIConnection ;
1113import org .rostilos .codecrow .security .oauth .TokenEncryptionService ;
1214import org .rostilos .codecrow .webserver .ai .dto .request .CreateAIConnectionRequest ;
1315import 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 ;
1421import org .springframework .stereotype .Service ;
1522import 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 ;
1730import java .security .GeneralSecurityException ;
31+ import java .time .Duration ;
1832import java .util .List ;
33+ import java .util .Map ;
1934import 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