diff --git a/src/main/java/com/crowdin/client/ai/AIApi.java b/src/main/java/com/crowdin/client/ai/AIApi.java index 9089572e..21e2b283 100644 --- a/src/main/java/com/crowdin/client/ai/AIApi.java +++ b/src/main/java/com/crowdin/client/ai/AIApi.java @@ -5,8 +5,10 @@ import com.crowdin.client.applications.installations.model.ApplicationInstallationResponseObject; import com.crowdin.client.core.CrowdinApi; import com.crowdin.client.core.http.HttpRequestConfig; +import com.crowdin.client.core.http.JsonTransformer; import com.crowdin.client.core.http.exceptions.HttpBadRequestException; import com.crowdin.client.core.http.exceptions.HttpException; +import com.crowdin.client.core.http.impl.json.JacksonJsonTransformer; import com.crowdin.client.core.model.ClientConfig; import com.crowdin.client.core.model.Credentials; import com.crowdin.client.core.model.DownloadLink; @@ -22,6 +24,8 @@ public class AIApi extends CrowdinApi { + private static final JsonTransformer AI_GATEWAY_JSON_TRANSFORMER = new JacksonJsonTransformer(); + public AIApi(Credentials credentials) { super(credentials); } @@ -927,6 +931,8 @@ public ResponseObject> aiGatewayPatch(final Long userId, fin /** * Sends a GET request through the AI gateway and returns the raw provider response body. + * The body must be a JSON object; for other response formats (arrays, scalars, empty bodies) use + * {@link #aiGatewayGetJson}. * * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param aiProviderId id of AiProvider @@ -945,6 +951,8 @@ public Map aiGatewayGetRaw(final Long userId, final Long aiProvi /** * Sends a POST request through the AI gateway and returns the raw provider response body. + * The body must be a JSON object; for other response formats (arrays, scalars, empty bodies) use + * {@link #aiGatewayPostJson}. * * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param aiProviderId id of AiProvider @@ -964,6 +972,8 @@ public Map aiGatewayPostRaw(final Long userId, final Long aiProv /** * Sends a PUT request through the AI gateway and returns the raw provider response body. + * The body must be a JSON object; for other response formats (arrays, scalars, empty bodies) use + * {@link #aiGatewayPutJson}. * * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param aiProviderId id of AiProvider @@ -983,6 +993,8 @@ public Map aiGatewayPutRaw(final Long userId, final Long aiProvi /** * Sends a PATCH request through the AI gateway and returns the raw provider response body. + * The body must be a JSON object; for other response formats (arrays, scalars, empty bodies) use + * {@link #aiGatewayPatchJson}. * * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param aiProviderId id of AiProvider @@ -1002,6 +1014,8 @@ public Map aiGatewayPatchRaw(final Long userId, final Long aiPro /** * Sends a DELETE request through the AI gateway and returns the raw provider response body. + * The body must be a JSON object; for other response formats (arrays, scalars, empty bodies) use + * {@link #aiGatewayDeleteJson}. * * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param aiProviderId id of AiProvider @@ -1018,6 +1032,138 @@ public Map aiGatewayDeleteRaw(final Long userId, final Long aiPr return this.httpClient.delete(url, new HttpRequestConfig(), Map.class); } + /** + * Sends a GET request through the AI gateway and returns the provider response body as any JSON value. + *

+ * The AI gateway is a transparent proxy, so the response format depends on the provider and endpoint. The whole + * body is read into memory: streamed responses (e.g. server-sent events) and non-JSON bodies are not supported, and + * an HTTP 204 (No Content) response cannot be read by the default HTTP client. + * + * @param userId user identifier; should be {@code null} for Crowdin Enterprise + * @param aiProviderId id of AiProvider + * @param path downstream endpoint path (e.g. models) + * @return the provider response body parsed as JSON: {@link Map} for an object, {@link List} for an array, + * {@link String}, {@link Number} or {@link Boolean} for a scalar, {@code null} for JSON {@code null} or an empty body + * @throws HttpException if the request fails or the response body is not valid JSON + * @see

+ */ + public Object aiGatewayGetJson(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.parseAiGatewayResponse(this.httpClient.get(url, new HttpRequestConfig(), String.class)); + } + + /** + * Sends a POST request through the AI gateway and returns the provider response body as any JSON value. + *

+ * The AI gateway is a transparent proxy, so the response format depends on the provider and endpoint. The whole + * body is read into memory: streamed responses (e.g. server-sent events) and non-JSON bodies are not supported, and + * an HTTP 204 (No Content) response cannot be read by the default HTTP client. + * + * @param userId user identifier; should be {@code null} for Crowdin Enterprise + * @param aiProviderId id of AiProvider + * @param path downstream endpoint path (e.g. models) + * @param request request body sent to the provider + * @return the provider response body parsed as JSON: {@link Map} for an object, {@link List} for an array, + * {@link String}, {@link Number} or {@link Boolean} for a scalar, {@code null} for JSON {@code null} or an empty body + * @throws HttpException if the request fails or the response body is not valid JSON + * @see

+ */ + public Object aiGatewayPostJson(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.parseAiGatewayResponse(this.httpClient.post(url, request, new HttpRequestConfig(), String.class)); + } + + /** + * Sends a PUT request through the AI gateway and returns the provider response body as any JSON value. + *

+ * The AI gateway is a transparent proxy, so the response format depends on the provider and endpoint. The whole + * body is read into memory: streamed responses (e.g. server-sent events) and non-JSON bodies are not supported, and + * an HTTP 204 (No Content) response cannot be read by the default HTTP client. + * + * @param userId user identifier; should be {@code null} for Crowdin Enterprise + * @param aiProviderId id of AiProvider + * @param path downstream endpoint path (e.g. models) + * @param request request body sent to the provider + * @return the provider response body parsed as JSON: {@link Map} for an object, {@link List} for an array, + * {@link String}, {@link Number} or {@link Boolean} for a scalar, {@code null} for JSON {@code null} or an empty body + * @throws HttpException if the request fails or the response body is not valid JSON + * @see

+ */ + public Object aiGatewayPutJson(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.parseAiGatewayResponse(this.httpClient.put(url, request, new HttpRequestConfig(), String.class)); + } + + /** + * Sends a PATCH request through the AI gateway and returns the provider response body as any JSON value. + *

+ * The AI gateway is a transparent proxy, so the response format depends on the provider and endpoint. The whole + * body is read into memory: streamed responses (e.g. server-sent events) and non-JSON bodies are not supported, and + * an HTTP 204 (No Content) response cannot be read by the default HTTP client. + * + * @param userId user identifier; should be {@code null} for Crowdin Enterprise + * @param aiProviderId id of AiProvider + * @param path downstream endpoint path (e.g. models) + * @param request request body sent to the provider + * @return the provider response body parsed as JSON: {@link Map} for an object, {@link List} for an array, + * {@link String}, {@link Number} or {@link Boolean} for a scalar, {@code null} for JSON {@code null} or an empty body + * @throws HttpException if the request fails or the response body is not valid JSON + * @see

+ */ + public Object aiGatewayPatchJson(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.parseAiGatewayResponse(this.httpClient.patch(url, request, new HttpRequestConfig(), String.class)); + } + + /** + * Sends a DELETE request through the AI gateway and returns the provider response body as any JSON value. + *

+ * The AI gateway is a transparent proxy, so the response format depends on the provider and endpoint. The whole + * body is read into memory: streamed responses (e.g. server-sent events) and non-JSON bodies are not supported, and + * an HTTP 204 (No Content) response cannot be read by the default HTTP client. + * + * @param userId user identifier; should be {@code null} for Crowdin Enterprise + * @param aiProviderId id of AiProvider + * @param path downstream endpoint path (e.g. models) + * @return the provider response body parsed as JSON: {@link Map} for an object, {@link List} for an array, + * {@link String}, {@link Number} or {@link Boolean} for a scalar, {@code null} for JSON {@code null} or an empty body + * @throws HttpException if the request fails or the response body is not valid JSON + * @see

+ */ + public Object aiGatewayDeleteJson(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.parseAiGatewayResponse(this.httpClient.delete(url, new HttpRequestConfig(), String.class)); + } + + private Object parseAiGatewayResponse(String body) { + if (body == null || body.trim().isEmpty()) { + return null; + } + JsonTransformer jsonTransformer = this.clientConfig != null && this.clientConfig.getJsonTransformer() != null + ? this.clientConfig.getJsonTransformer() + : AI_GATEWAY_JSON_TRANSFORMER; + try { + return jsonTransformer.parse(body, Object.class); + } catch (Exception e) { + throw HttpException.fromMessage("AI gateway response is not valid JSON: " + e.getMessage(), body); + } + } + /** * @param userId user identifier; should be {@code null} for Crowdin Enterprise * @param request export filters diff --git a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java index 310b8317..6cfa55d6 100644 --- a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java +++ b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java @@ -12,12 +12,19 @@ public class AiPromptCompletionRequest { private AiPromptResource resources; /** - * @deprecated the API expects an array of tools; use {@link #aiTools} instead. This field is not sent to the API. + * Legacy tools map. Values set through {@code setTools(Map)} are only stored and returned by {@code getTools()}; + * they are not sent to the API. + * + * @deprecated the API expects an array of tools; use {@code setAiTools(List)} ({@link #aiTools}) instead */ @Deprecated @JsonIgnore private Map tools; + /** + * Tools available to the model (JSON key {@code tools}); each entry is + * {@code {"tool": {"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}}} + */ @JsonProperty("tools") private List aiTools; @@ -35,7 +42,8 @@ public class AiPromptCompletionRequest { private Object toolChoice; /** - * @deprecated use {@link #toolChoice} instead + * @param tool_choice tool choice object + * @deprecated use {@code setToolChoice(Object)} ({@link #toolChoice}) instead */ @Deprecated public void setTool_choice(Map tool_choice) { @@ -44,7 +52,8 @@ public void setTool_choice(Map tool_choice) { } /** - * @deprecated use {@link #toolChoice} instead + * @return {@link #toolChoice} if it is an object, otherwise {@code null} + * @deprecated use {@code getToolChoice()} ({@link #toolChoice}) instead */ @Deprecated @SuppressWarnings("unchecked") diff --git a/src/main/java/com/crowdin/client/ai/model/Credentials.java b/src/main/java/com/crowdin/client/ai/model/Credentials.java index e4a01719..da83fb85 100644 --- a/src/main/java/com/crowdin/client/ai/model/Credentials.java +++ b/src/main/java/com/crowdin/client/ai/model/Credentials.java @@ -1,18 +1,44 @@ package com.crowdin.client.ai.model; import lombok.Data; +import lombok.ToString; import java.util.List; import java.util.Map; /** - * AI provider credentials. Superset of the credential fields of all provider types; - * set only the fields required by the chosen provider type. + * AI provider credentials. Superset of the credential fields of all provider types; set only the fields of the + * chosen provider type (the SDK does not validate the combination, the API does). + *

+ * Supported provider types and their credential fields (required fields in bold): + *

    + *
  • {@code open_ai}, {@code google_gemini_ai_studio}, {@code mistral_ai}, {@code anthropic}, {@code x_ai}, + * {@code deepseek}: apiKey, baseUrl, headers, sendCustomHeadersOnly
  • + *
  • {@code azure_open_ai}: resourceName, apiKey, deploymentName, apiVersion, baseUrl, + * headers, sendCustomHeadersOnly
  • + *
  • {@code google_gemini} (Vertex AI): project, region, serviceAccountKey or + * workloadIdentityFederationAudience (one of them is required), serviceAccountEmail, baseUrl, headers, + * sendCustomHeadersOnly
  • + *
  • {@code watsonx}: apiKey, projectId, region, baseUrl, headers, sendCustomHeadersOnly
  • + *
  • {@code microsoft_foundry}: deployments (each with deploymentName, targetUri, + * apiKey), headers, sendCustomHeadersOnly
  • + *
  • {@code custom_ai}: identifier, key
  • + *
+ * Secret values ({@code apiKey}, {@code headers}, {@code serviceAccountKey}, {@code key} and the deployments' API keys) + * are sent to the API as usual but are excluded from {@code toString()}. */ @Data public class Credentials { + @ToString.Exclude private String apiKey; + /** + * Override the provider API base URL (e.g. a self-hosted proxy or third-party gateway) + */ private String baseUrl; + /** + * Custom HTTP headers sent with every request to the provider; may contain authorization values + */ + @ToString.Exclude private Map headers; private Boolean sendCustomHeadersOnly; @@ -28,6 +54,10 @@ public class Credentials { */ private String project; private String region; + /** + * Google Gemini (Vertex AI) service account key (contains a private key) + */ + @ToString.Exclude private Map serviceAccountKey; private String workloadIdentityFederationAudience; private String serviceAccountEmail; @@ -46,12 +76,17 @@ public class Credentials { * Custom AI */ private String identifier; + @ToString.Exclude private String key; + /** + * Microsoft Foundry deployment + */ @Data public static class Deployment { private String deploymentName; private String targetUri; + @ToString.Exclude private String apiKey; } } diff --git a/src/main/java/com/crowdin/client/ai/model/FineTuningJob.java b/src/main/java/com/crowdin/client/ai/model/FineTuningJob.java index f3aae2c0..9d89a4b3 100644 --- a/src/main/java/com/crowdin/client/ai/model/FineTuningJob.java +++ b/src/main/java/com/crowdin/client/ai/model/FineTuningJob.java @@ -46,7 +46,8 @@ public static class Hyperparameters { private Long nEpochs; /** - * @deprecated use {@link #learningRateMultiplierValue} instead + * @param learningRateMultiplier learning rate multiplier as a whole number + * @deprecated use {@code setLearningRateMultiplierValue(Double)} ({@link #learningRateMultiplierValue}) instead */ @Deprecated public void setLearningRateMultiplier(Long learningRateMultiplier) { @@ -55,11 +56,20 @@ public void setLearningRateMultiplier(Long learningRateMultiplier) { } /** - * @deprecated use {@link #learningRateMultiplierValue} instead + * Legacy whole-number view of {@link #learningRateMultiplierValue}. A finite value within the {@link Long} + * range is truncated towards zero (e.g. {@code 1.8} → {@code 1}, {@code -0.5} → {@code 0}); a value + * outside the {@link Long} range, {@code NaN} or an infinite value is returned as {@code null}. + * + * @return learning rate multiplier truncated to a whole number, or {@code null} + * @deprecated use {@code getLearningRateMultiplierValue()} ({@link #learningRateMultiplierValue}) instead */ @Deprecated public Long getLearningRateMultiplier() { - return this.learningRateMultiplierValue != null ? this.learningRateMultiplierValue.longValue() : null; + Double value = this.learningRateMultiplierValue; + if (value == null || value.isNaN() || value.isInfinite() || value < -0x1p63 || value >= 0x1p63) { + return null; + } + return value.longValue(); } } diff --git a/src/main/java/com/crowdin/client/ai/model/FineTuningJobRequest.java b/src/main/java/com/crowdin/client/ai/model/FineTuningJobRequest.java index fcf00b71..cf87b691 100644 --- a/src/main/java/com/crowdin/client/ai/model/FineTuningJobRequest.java +++ b/src/main/java/com/crowdin/client/ai/model/FineTuningJobRequest.java @@ -27,7 +27,8 @@ public static class Hyperparameters { private Long nEpochs; /** - * @deprecated use {@link #learningRateMultiplierValue} instead + * @param learningRateMultiplier learning rate multiplier as a whole number + * @deprecated use {@code setLearningRateMultiplierValue(Double)} ({@link #learningRateMultiplierValue}) instead */ @Deprecated public void setLearningRateMultiplier(Long learningRateMultiplier) { @@ -36,11 +37,31 @@ public void setLearningRateMultiplier(Long learningRateMultiplier) { } /** - * @deprecated use {@link #learningRateMultiplierValue} instead + * Legacy whole-number view of {@link #learningRateMultiplierValue}. A finite value within the {@link Long} + * range is truncated towards zero (e.g. {@code 1.8} → {@code 1}, {@code -0.5} → {@code 0}); a value + * outside the {@link Long} range, {@code NaN} or an infinite value is returned as {@code null}. + * + * @return learning rate multiplier truncated to a whole number, or {@code null} + * @deprecated use {@code getLearningRateMultiplierValue()} ({@link #learningRateMultiplierValue}) instead */ @Deprecated public Long getLearningRateMultiplier() { - return this.learningRateMultiplierValue != null ? this.learningRateMultiplierValue.longValue() : null; + Double value = this.learningRateMultiplierValue; + if (value == null || value.isNaN() || value.isInfinite() || value < -0x1p63 || value >= 0x1p63) { + return null; + } + return value.longValue(); + } + + /** + * @param learningRateMultiplierValue learning rate multiplier + * @throws IllegalArgumentException if the value is {@code NaN} or infinite, which the API does not accept + */ + public void setLearningRateMultiplierValue(Double learningRateMultiplierValue) { + if (learningRateMultiplierValue != null && (learningRateMultiplierValue.isNaN() || learningRateMultiplierValue.isInfinite())) { + throw new IllegalArgumentException("learningRateMultiplier must be a finite number, got " + learningRateMultiplierValue); + } + this.learningRateMultiplierValue = learningRateMultiplierValue; } } diff --git a/src/test/java/com/crowdin/client/ai/AIApiTest.java b/src/test/java/com/crowdin/client/ai/AIApiTest.java index e6bf1f67..c5452d52 100644 --- a/src/test/java/com/crowdin/client/ai/AIApiTest.java +++ b/src/test/java/com/crowdin/client/ai/AIApiTest.java @@ -898,6 +898,23 @@ public void aiGatewayDeleteRawTest() { assertEquals("chatcmpl-123", response.get("id")); } + @Test + @SuppressWarnings("unchecked") + public void aiGatewayJsonMethodsTest() { + Map req = new HashMap<>(); + req.put("model", "string"); + Map get = (Map) this.getAiApi().aiGatewayGetJson(userId, 1L, rawGatewayPath); + assertEquals("chatcmpl-123", get.get("id")); + Map post = (Map) this.getAiApi().aiGatewayPostJson(userId, 1L, rawGatewayPath, req); + assertEquals(1, ((List) post.get("choices")).size()); + Map put = (Map) this.getAiApi().aiGatewayPutJson(userId, 1L, rawGatewayPath, req); + assertEquals("gpt-5.4", put.get("model")); + Map patch = (Map) this.getAiApi().aiGatewayPatchJson(userId, 1L, rawGatewayPath, req); + assertEquals("gpt-5.4", patch.get("model")); + Map delete = (Map) this.getAiApi().aiGatewayDeleteJson(userId, 1L, rawGatewayPath); + assertEquals("chatcmpl-123", delete.get("id")); + } + @Test public void cancelAiPromptCompletionTest() { this.getAiApi().cancelPromptCompletion(userId, aiPromptId, completionId); diff --git a/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java b/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java new file mode 100644 index 00000000..bd8a44c5 --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java @@ -0,0 +1,200 @@ +package com.crowdin.client.ai; + +import com.crowdin.client.core.http.exceptions.HttpException; +import com.crowdin.client.core.model.Credentials; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises the AI gateway methods (the Map-returning {@code aiGateway*Raw} and the {@code aiGateway*Json}) through the real {@code ApacheHttpClient} and {@code JacksonJsonTransformer} + * against a local HTTP server. + */ +class AiGatewayRawHttpTest { + + private static final Map RESPONSES = new LinkedHashMap<>(); + + static { + RESPONSES.put("object", new String[]{"200", "{\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0}]}"}); + RESPONSES.put("array", new String[]{"200", "[{\"id\":\"model-a\"},{\"id\":\"model-b\"}]"}); + RESPONSES.put("string", new String[]{"200", "\"plain\""}); + RESPONSES.put("integer", new String[]{"200", "42"}); + RESPONSES.put("decimal", new String[]{"200", "0.75"}); + RESPONSES.put("boolean", new String[]{"200", "true"}); + RESPONSES.put("jsonnull", new String[]{"200", "null"}); + RESPONSES.put("empty", new String[]{"200", ""}); + RESPONSES.put("blank", new String[]{"200", " \n"}); + RESPONSES.put("text", new String[]{"200", "not json"}); + RESPONSES.put("wrapped", new String[]{"200", "{\"data\":{\"id\":\"x\"}}"}); + RESPONSES.put("error", new String[]{"400", "{\"error\":{\"code\":400,\"message\":\"provider rejected the request\"}}"}); + RESPONSES.put("nocontent", new String[]{"204", null}); + } + + private final List requests = new CopyOnWriteArrayList<>(); + private HttpServer server; + private AIApi api; + + @BeforeEach + void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String body = read(exchange.getRequestBody()); + requests.add(new String[]{exchange.getRequestMethod(), exchange.getRequestURI().toString(), body, + exchange.getRequestHeaders().getFirst("Authorization")}); + String path = exchange.getRequestURI().getPath(); + String[] response = RESPONSES.get(path.substring(path.lastIndexOf('/') + 1)); + int status = Integer.parseInt(response[0]); + if (response[1] == null) { + exchange.sendResponseHeaders(status, -1); + } else { + byte[] bytes = response[1].getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) { + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + } + exchange.close(); + }); + server.start(); + api = new AIApi(new Credentials("test-token", null, "http://127.0.0.1:" + server.getAddress().getPort())); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + @SuppressWarnings("unchecked") + void jsonResponseTypesTest() { + Map object = (Map) api.aiGatewayGetJson(7L, 3L, "v1/object"); + assertEquals("chatcmpl-1", object.get("id")); + assertEquals(1, ((List) object.get("choices")).size()); + + List array = (List) api.aiGatewayGetJson(7L, 3L, "v1/array"); + assertEquals(2, array.size()); + assertEquals("model-b", ((Map) array.get(1)).get("id")); + + assertEquals("plain", api.aiGatewayGetJson(7L, 3L, "v1/string")); + assertEquals(42, ((Number) api.aiGatewayGetJson(7L, 3L, "v1/integer")).intValue()); + assertEquals(0.75, ((Number) api.aiGatewayGetJson(7L, 3L, "v1/decimal")).doubleValue()); + assertEquals(Boolean.TRUE, api.aiGatewayGetJson(7L, 3L, "v1/boolean")); + assertNull(api.aiGatewayGetJson(7L, 3L, "v1/jsonnull")); + } + + @Test + void emptyBodyIsNullTest() { + assertNull(api.aiGatewayGetJson(7L, 3L, "v1/empty")); + assertNull(api.aiGatewayDeleteJson(null, 3L, "v1/empty")); + assertNull(api.aiGatewayPostJson(7L, 3L, "v1/blank", Collections.singletonMap("model", "m"))); + } + + @Test + void nonJsonBodyFailsWithHttpExceptionTest() { + HttpException e = assertThrows(HttpException.class, () -> api.aiGatewayGetJson(7L, 3L, "v1/text")); + assertEquals("not json", e.getHttpResponse()); + assertTrue(e.getError().getMessage().startsWith("AI gateway response is not valid JSON"), e.getError().getMessage()); + } + + @Test + void errorStatusFailsWithHttpExceptionTest() { + HttpException e = assertThrows(HttpException.class, () -> api.aiGatewayPostJson(7L, 3L, "v1/error", Collections.singletonMap("model", "m"))); + assertEquals("provider rejected the request", e.getError().getMessage()); + } + + @Test + void mapReturningRawMethodsKeepReleasedBehaviourTest() { + Map object = api.aiGatewayGetRaw(7L, 3L, "v1/object"); + assertEquals("chatcmpl-1", object.get("id")); + assertEquals("chatcmpl-1", api.aiGatewayPostRaw(7L, 3L, "v1/object", Collections.singletonMap("model", "m")).get("id")); + assertEquals("chatcmpl-1", api.aiGatewayPutRaw(7L, 3L, "v1/object", Collections.singletonMap("model", "m")).get("id")); + assertEquals("chatcmpl-1", api.aiGatewayPatchRaw(null, 3L, "v1/object", Collections.singletonMap("model", "m")).get("id")); + assertEquals("chatcmpl-1", api.aiGatewayDeleteRaw(null, 3L, "v1/object").get("id")); + assertNull(api.aiGatewayGetRaw(7L, 3L, "v1/jsonnull")); + // 1.37.0 behaviour: only JSON objects can be returned as a Map; use the *Json methods for other formats + for (String mode : new String[]{"array", "string", "integer", "boolean", "empty", "text"}) { + assertThrows(HttpException.class, () -> api.aiGatewayGetRaw(7L, 3L, "v1/" + mode), mode); + } + HttpException error = assertThrows(HttpException.class, () -> api.aiGatewayPostRaw(7L, 3L, "v1/error", Collections.singletonMap("model", "m"))); + assertEquals("provider rejected the request", error.getError().getMessage()); + } + + /** + * Documented limitation: the default HTTP client cannot read a 204 (No Content) response for a typed result; + * this is a transport behaviour shared by all SDK methods and is not changed here. + */ + @Test + void noContentResponseIsNotSupportedTest() { + assertThrows(RuntimeException.class, () -> api.aiGatewayDeleteJson(7L, 3L, "v1/nocontent")); + assertThrows(RuntimeException.class, () -> api.aiGatewayDeleteRaw(7L, 3L, "v1/nocontent")); + } + + @Test + @SuppressWarnings("unchecked") + void requestsUseDocumentedMethodsAndPathsTest() { + Map body = new LinkedHashMap<>(); + body.put("model", "gpt"); + body.put("messages", Collections.singletonList(Collections.singletonMap("role", "user"))); + + api.aiGatewayGetJson(7L, 3L, "v1/object"); + api.aiGatewayPostJson(7L, 3L, "v1/object", body); + api.aiGatewayPutRaw(7L, 3L, "v1/object", body); + api.aiGatewayPatchJson(null, 3L, "v1/object", body); + api.aiGatewayDeleteRaw(null, 3L, "v1/object"); + + List expected = Arrays.asList( + "GET /api/v2/users/7/ai/providers/3/gateway/v1/object", + "POST /api/v2/users/7/ai/providers/3/gateway/v1/object", + "PUT /api/v2/users/7/ai/providers/3/gateway/v1/object", + "PATCH /api/v2/ai/providers/3/gateway/v1/object", + "DELETE /api/v2/ai/providers/3/gateway/v1/object"); + assertEquals(5, requests.size()); + for (int i = 0; i < expected.size(); i++) { + String[] request = requests.get(i); + assertEquals(expected.get(i), request[0] + " " + request[1]); + assertEquals("Bearer test-token", request[3]); + } + assertEquals("{\"model\":\"gpt\",\"messages\":[{\"role\":\"user\"}]}", requests.get(1)[2]); + assertEquals("", requests.get(0)[2]); + } + + @Test + @SuppressWarnings("deprecation") + void legacyGatewayMethodsAreUnchangedTest() { + assertEquals("x", api.aiGatewayGet(7L, 3L, "v1/wrapped").getData().get("id")); + assertNull(api.aiGatewayGet(7L, 3L, "v1/object").getData()); + api.aiGatewayDelete(7L, 3L, "v1/nocontent"); + assertThrows(HttpException.class, () -> api.aiGatewayGet(7L, 3L, "v1/array")); + } + + private static String read(InputStream in) throws java.io.IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java b/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java new file mode 100644 index 00000000..1a608cb5 --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java @@ -0,0 +1,113 @@ +package com.crowdin.client.ai.model; + +import com.crowdin.client.core.http.impl.json.JacksonJsonTransformer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code setTools(Map)} keeps its SDK 1.37.0 behavior: the map is only stored and is never sent. The documented + * {@code tools} array is sent through {@code setAiTools(List)}. + */ +@SuppressWarnings("deprecation") +class AiPromptCompletionRequestToolsTest { + + private final JacksonJsonTransformer jsonTransformer = new JacksonJsonTransformer(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void legacyToolsMapIsStoredButNotSentTest() { + Map legacy = new LinkedHashMap<>(); + legacy.put("tool", Collections.singletonMap("type", "function")); + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setTools(legacy); + + assertSame(legacy, request.getTools()); + assertNull(request.getAiTools()); + assertEquals("{}", jsonTransformer.convert(request)); + + request.setTools(Collections.emptyMap()); + assertTrue(request.getTools().isEmpty()); + assertEquals("{}", jsonTransformer.convert(request)); + + request.setTools(null); + assertNull(request.getTools()); + assertEquals("{}", jsonTransformer.convert(request)); + } + + @Test + void anyLegacyMapShapeIsAcceptedWithoutValidationTest() { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + Map unexpected = Collections.singletonMap("ignored", "value"); + request.setTools(unexpected); + assertSame(unexpected, request.getTools()); + assertEquals("{}", jsonTransformer.convert(request)); + } + + @Test + void aiToolsAreSentAsTheDocumentedArrayTest() { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setAiTools(Collections.singletonList(tool("get_weather", "Weather", Collections.singletonMap("type", "object")))); + + assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"," + + "\"description\":\"Weather\",\"parameters\":{\"type\":\"object\"}}}}]}", jsonTransformer.convert(request)); + + request.setAiTools(Collections.emptyList()); + assertEquals("{\"tools\":[]}", jsonTransformer.convert(request)); + } + + @Test + void legacyMapAndAiToolsProduceOneToolsPropertyTest() { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setTools(Collections.singletonMap("tool", Collections.singletonMap("type", "function"))); + request.setAiTools(Collections.singletonList(tool("f", null, null))); + String json = jsonTransformer.convert(request); + + assertEquals(1, json.split("\"tools\"", -1).length - 1, json); + assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"f\"}}}]}", json); + assertEquals(1, request.getAiTools().size()); + assertEquals(1, request.getTools().size()); + } + + @Test + void parsedRequestUsesAiToolsOnlyTest() { + AiPromptCompletionRequest parsed = jsonTransformer.parse( + "{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"f\"}}}]}", AiPromptCompletionRequest.class); + List tools = parsed.getAiTools(); + assertEquals("f", tools.get(0).getTool().getFunction().getName()); + assertNull(parsed.getTools()); + assertFalse(jsonTransformer.convert(parsed).contains("ignored")); + } + + private static AiPromptCompletionRequest.AiTool tool(String name, String description, Map parameters) { + AiPromptCompletionRequest.ToolFunction function = new AiPromptCompletionRequest.ToolFunction(); + function.setName(name); + function.setDescription(description); + function.setParameters(parameters); + AiPromptCompletionRequest.Tool tool = new AiPromptCompletionRequest.Tool(); + tool.setType("function"); + tool.setFunction(function); + AiPromptCompletionRequest.AiTool aiTool = new AiPromptCompletionRequest.AiTool(); + aiTool.setTool(tool); + return aiTool; + } + + @SneakyThrows + private void assertJsonEquals(String expected, String actual) { + JsonNode e = objectMapper.readTree(expected); + JsonNode a = objectMapper.readTree(actual); + assertEquals(e, a, actual); + } +} diff --git a/src/test/java/com/crowdin/client/ai/model/CredentialsTest.java b/src/test/java/com/crowdin/client/ai/model/CredentialsTest.java new file mode 100644 index 00000000..b346c0ab --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/model/CredentialsTest.java @@ -0,0 +1,92 @@ +package com.crowdin.client.ai.model; + +import com.crowdin.client.core.http.impl.json.JacksonJsonTransformer; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CredentialsTest { + + private static final String API_KEY = "sk-api-key-secret"; + private static final String HEADER_TOKEN = "Bearer header-token-secret"; + private static final String PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----service-account-secret"; + private static final String CUSTOM_KEY = "custom-ai-key-secret"; + private static final String DEPLOYMENT_KEY = "foundry-deployment-key-secret"; + private static final String[] SECRETS = {API_KEY, HEADER_TOKEN, PRIVATE_KEY, CUSTOM_KEY, DEPLOYMENT_KEY}; + + private final JacksonJsonTransformer jsonTransformer = new JacksonJsonTransformer(); + + @Test + void toStringDoesNotExposeSecretsTest() { + Credentials credentials = credentials(); + AiProviderRequest request = new AiProviderRequest(); + request.setName("provider"); + request.setType("open_ai"); + request.setCredentials(credentials); + AiProvider provider = new AiProvider(); + provider.setCredentials(credentials); + + for (String text : new String[]{credentials.toString(), credentials.getDeployments().get(0).toString(), + request.toString(), provider.toString()}) { + for (String secret : SECRETS) { + assertFalse(text.contains(secret), "secret leaked in toString: " + text); + } + } + assertTrue(credentials.toString().contains("https://proxy.example.com")); + assertTrue(credentials.toString().contains("my-gcp-project")); + assertTrue(credentials.getDeployments().get(0).toString().contains("https://foundry.example.com")); + } + + @Test + void serializationStillIncludesCredentialsTest() { + String json = jsonTransformer.convert(credentials()); + for (String secret : SECRETS) { + assertTrue(json.contains(secret), "secret missing from request JSON: " + secret); + } + Credentials parsed = jsonTransformer.parse(json, Credentials.class); + assertEquals(API_KEY, parsed.getApiKey()); + assertEquals(HEADER_TOKEN, parsed.getHeaders().get("Authorization")); + assertEquals(PRIVATE_KEY, parsed.getServiceAccountKey().get("private_key")); + assertEquals(CUSTOM_KEY, parsed.getKey()); + assertEquals(DEPLOYMENT_KEY, parsed.getDeployments().get(0).getApiKey()); + assertEquals(credentials(), parsed); + } + + @Test + void secretsStillTakePartInEqualsTest() { + Credentials other = credentials(); + other.setApiKey("another-key"); + assertNotEquals(credentials(), other); + } + + private static Credentials credentials() { + Credentials credentials = new Credentials(); + credentials.setApiKey(API_KEY); + credentials.setBaseUrl("https://proxy.example.com"); + Map headers = new LinkedHashMap<>(); + headers.put("Authorization", HEADER_TOKEN); + credentials.setHeaders(headers); + credentials.setSendCustomHeadersOnly(true); + credentials.setProject("my-gcp-project"); + credentials.setRegion("us-central1"); + Map serviceAccountKey = new LinkedHashMap<>(); + serviceAccountKey.put("type", "service_account"); + serviceAccountKey.put("private_key", PRIVATE_KEY); + credentials.setServiceAccountKey(serviceAccountKey); + credentials.setIdentifier("custom-ai-identifier"); + credentials.setKey(CUSTOM_KEY); + Credentials.Deployment deployment = new Credentials.Deployment(); + deployment.setDeploymentName("gpt-deployment"); + deployment.setTargetUri("https://foundry.example.com"); + deployment.setApiKey(DEPLOYMENT_KEY); + credentials.setDeployments(Collections.singletonList(deployment)); + return credentials; + } +} diff --git a/src/test/java/com/crowdin/client/ai/model/FineTuningHyperparametersTest.java b/src/test/java/com/crowdin/client/ai/model/FineTuningHyperparametersTest.java new file mode 100644 index 00000000..46680050 --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/model/FineTuningHyperparametersTest.java @@ -0,0 +1,91 @@ +package com.crowdin.client.ai.model; + +import com.crowdin.client.core.http.impl.json.JacksonJsonTransformer; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings("deprecation") +class FineTuningHyperparametersTest { + + private final JacksonJsonTransformer jsonTransformer = new JacksonJsonTransformer(); + + @Test + void legacyGetterTruncatesFiniteValuesTest() { + assertEquals(Long.valueOf(0), requestLegacy(0.1)); + assertEquals(Long.valueOf(1), requestLegacy(1.8)); + assertEquals(Long.valueOf(0), requestLegacy(-0.5)); + assertEquals(Long.valueOf(-1), requestLegacy(-1.9)); + assertEquals(Long.valueOf(2), requestLegacy(2.0)); + assertEquals(Long.valueOf(1), responseLegacy("1.8")); + assertEquals(Long.valueOf(0), responseLegacy("0.1")); + } + + @Test + void legacyGetterLongBoundariesTest() { + assertEquals(Long.valueOf(Long.MIN_VALUE), requestLegacy((double) Long.MIN_VALUE)); + assertEquals(Long.valueOf(9223372036854774784L), requestLegacy(9.223372036854774784E18)); + assertNull(requestLegacy(9.223372036854775807E18)); + assertNull(requestLegacy(1e30)); + assertNull(requestLegacy(-1e30)); + assertNull(responseLegacy("1e30")); + assertNull(responseLegacy("-1e19")); + } + + @Test + void legacyGetterNonFiniteAndNullValuesTest() { + FineTuningJob.Hyperparameters response = new FineTuningJob.Hyperparameters(); + response.setLearningRateMultiplierValue(Double.NaN); + assertNull(response.getLearningRateMultiplier()); + response.setLearningRateMultiplierValue(Double.POSITIVE_INFINITY); + assertNull(response.getLearningRateMultiplier()); + response.setLearningRateMultiplierValue(Double.NEGATIVE_INFINITY); + assertNull(response.getLearningRateMultiplier()); + response.setLearningRateMultiplierValue(null); + assertNull(response.getLearningRateMultiplier()); + assertNull(responseLegacy("null")); + } + + @Test + void requestRejectsNonFiniteValuesTest() { + FineTuningJobRequest.Hyperparameters hyperparameters = new FineTuningJobRequest.Hyperparameters(); + hyperparameters.setLearningRateMultiplierValue(0.5); + for (Double invalid : new Double[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + assertThrows(IllegalArgumentException.class, () -> hyperparameters.setLearningRateMultiplierValue(invalid), String.valueOf(invalid)); + assertEquals(Double.valueOf(0.5), hyperparameters.getLearningRateMultiplierValue()); + } + assertEquals("{\"learningRateMultiplier\":0.5}", jsonTransformer.convert(hyperparameters)); + } + + @Test + void requestSerializationTest() { + FineTuningJobRequest.Hyperparameters hyperparameters = new FineTuningJobRequest.Hyperparameters(); + hyperparameters.setBatchSize(4L); + hyperparameters.setNEpochs(3L); + hyperparameters.setLearningRateMultiplierValue(0.05); + String json = jsonTransformer.convert(hyperparameters); + assertEquals("{\"batchSize\":4,\"nEpochs\":3,\"learningRateMultiplier\":0.05}", json); + + hyperparameters.setLearningRateMultiplier(2L); + assertEquals(Double.valueOf(2.0), hyperparameters.getLearningRateMultiplierValue()); + assertEquals(Long.valueOf(2), hyperparameters.getLearningRateMultiplier()); + assertEquals("{\"batchSize\":4,\"nEpochs\":3,\"learningRateMultiplier\":2.0}", jsonTransformer.convert(hyperparameters)); + + hyperparameters.setLearningRateMultiplier(null); + assertNull(hyperparameters.getLearningRateMultiplierValue()); + assertEquals("{\"batchSize\":4,\"nEpochs\":3}", jsonTransformer.convert(hyperparameters)); + } + + private static Long requestLegacy(double value) { + FineTuningJobRequest.Hyperparameters hyperparameters = new FineTuningJobRequest.Hyperparameters(); + hyperparameters.setLearningRateMultiplierValue(value); + return hyperparameters.getLearningRateMultiplier(); + } + + private Long responseLegacy(String jsonValue) { + return jsonTransformer.parse("{\"learningRateMultiplier\":" + jsonValue + "}", FineTuningJob.Hyperparameters.class) + .getLearningRateMultiplier(); + } +}