From 22bb175f7ab5b61b94c5d878a67bed52cbff3d5d Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Tue, 27 Oct 2026 21:48:05 -0700 Subject: [PATCH 1/5] fix(ai): keep provider secrets out of Credentials.toString() Exclude apiKey, headers, serviceAccountKey, the Custom AI key and the Microsoft Foundry deployment apiKey from the Lombok-generated toString() of Credentials and Credentials.Deployment, so logging an AiProvider or AiProviderRequest no longer prints secrets. JSON serialization, equals and the public getters/setters are unchanged. Document the supported provider credential variants and their required fields from the official OpenAPI. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../crowdin/client/ai/model/Credentials.java | 39 +++++++- .../client/ai/model/CredentialsTest.java | 92 +++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/crowdin/client/ai/model/CredentialsTest.java 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): + *

+ * 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/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; + } +} From 83de587f6c9a41bda53381f45d7e65e8dfc72ae8 Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Tue, 27 Oct 2026 21:48:06 -0700 Subject: [PATCH 2/5] fix(ai): handle learningRateMultiplier edge cases The deprecated Long getLearningRateMultiplier() now returns null for NaN, infinite and out-of-range values instead of clamping them to Long.MAX_VALUE/MIN_VALUE or returning 0; finite values within range are still truncated towards zero, matching Config.getMin()/getMax(). FineTuningJobRequest.Hyperparameters.setLearningRateMultiplierValue() rejects NaN and infinite values, which would otherwise be sent as the invalid JSON strings "NaN"/"Infinity". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../client/ai/model/FineTuningJob.java | 16 +++- .../client/ai/model/FineTuningJobRequest.java | 27 +++++- .../model/FineTuningHyperparametersTest.java | 91 +++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/crowdin/client/ai/model/FineTuningHyperparametersTest.java 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/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(); + } +} From ce408747360b22353714c3e470c3929ce70d6031 Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Tue, 27 Oct 2026 21:48:06 -0700 Subject: [PATCH 3/5] fix(ai): drop unreleased deprecated fields, validate legacy tools, return raw gateway bodies as Object - remove AiPromptCompletionData.eta and ProjectAiSettings.preTranslationAiPromptId, added in #424 but deprecated in the API (AiSetting.preTranslationAiPromptId, which is not deprecated, stays) - the deprecated setTools(Map) converts the documented tool shapes ({"tool": {...}} or {"type": "function", "function": {...}}) into the tools array instead of silently dropping the value; any other structure is rejected with IllegalArgumentException - aiGateway*Raw return Object (Map, List, String, Number, Boolean or null) because the gateway response format depends on the provider; an empty body is null and a non-JSON body raises HttpException. Legacy aiGateway* methods are unchanged - fill in missing Javadoc tags Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/com/crowdin/client/ai/AIApi.java | 97 +++++++--- .../ai/model/AiPromptCompletionRequest.java | 109 ++++++++++- .../ai/model/AiPromptCompletionResponse.java | 5 - .../client/ai/model/ProjectAiSettings.java | 5 - .../java/com/crowdin/client/ai/AIApiTest.java | 19 +- .../client/ai/AiGatewayRawHttpTest.java | 182 ++++++++++++++++++ .../AiPromptCompletionRequestToolsTest.java | 128 ++++++++++++ .../api/ai/projectAiSettingsResponse.json | 1 - .../api/ai/promptCompletionResponse.json | 3 +- 9 files changed, 498 insertions(+), 51 deletions(-) create mode 100644 src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java create mode 100644 src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java diff --git a/src/main/java/com/crowdin/client/ai/AIApi.java b/src/main/java/com/crowdin/client/ai/AIApi.java index 9089572e..6431587a 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,95 +931,134 @@ public ResponseObject> aiGatewayPatch(final Long userId, fin /** * Sends a GET request through the AI gateway and returns the raw provider response body. + *

+ * 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 raw provider response body + * @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

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

+ * 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. chat/completions) - * @param request raw provider request body - * @return raw provider response body + * @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

*/ - @SuppressWarnings("unchecked") - public Map aiGatewayPostRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + public Object aiGatewayPostRaw(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.httpClient.post(url, request, new HttpRequestConfig(), Map.class); + return this.parseAiGatewayResponse(this.httpClient.post(url, request, new HttpRequestConfig(), String.class)); } /** * Sends a PUT request through the AI gateway and returns the raw provider response body. + *

+ * 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 - * @param request raw provider request body - * @return raw provider response body + * @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

*/ - @SuppressWarnings("unchecked") - public Map aiGatewayPutRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + public Object aiGatewayPutRaw(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.httpClient.put(url, request, new HttpRequestConfig(), Map.class); + return this.parseAiGatewayResponse(this.httpClient.put(url, request, new HttpRequestConfig(), String.class)); } /** * Sends a PATCH request through the AI gateway and returns the raw provider response body. + *

+ * 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 - * @param request raw provider request body - * @return raw provider response body + * @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

*/ - @SuppressWarnings("unchecked") - public Map aiGatewayPatchRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + public Object aiGatewayPatchRaw(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.httpClient.patch(url, request, new HttpRequestConfig(), Map.class); + return this.parseAiGatewayResponse(this.httpClient.patch(url, request, new HttpRequestConfig(), String.class)); } /** * Sends a DELETE request through the AI gateway and returns the raw provider response body. + *

+ * 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 - * @return raw provider response body + * @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

*/ - @SuppressWarnings("unchecked") - public Map aiGatewayDeleteRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + public Object aiGatewayDeleteRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); - return this.httpClient.delete(url, new HttpRequestConfig(), Map.class); + 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); + } } /** 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..7c05f864 100644 --- a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java +++ b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java @@ -4,6 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -12,12 +15,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 map view of the tools, as passed to {@code setTools(Map)}. It is never sent as is; the deprecated setter + * converts it into {@link #aiTools}. + * + * @deprecated the API expects an array of tools; use {@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 +45,28 @@ public class AiPromptCompletionRequest { private Object toolChoice; /** - * @deprecated use {@link #toolChoice} instead + * Legacy setter for the tools. The map is converted into a single {@link #aiTools} entry when it has one of the + * documented shapes: + *
    + *
  • a tools array entry: {@code {"tool": {"type": "function", "function": {...}}}}
  • + *
  • the tool object itself: {@code {"type": "function", "function": {...}}}
  • + *
+ * where {@code function} contains {@code name} (required, string), and optionally {@code description} (string) + * and {@code parameters} (object). An empty map sets an empty tools array and {@code null} clears the tools. + * + * @param tools legacy tools map + * @throws IllegalArgumentException if the map has any other structure; nothing is changed in that case + * @deprecated the API expects an array of tools; use {@code setAiTools(List)} ({@link #aiTools}) instead + */ + @Deprecated + public void setTools(Map tools) { + this.aiTools = toAiTools(tools); + this.tools = tools; + } + + /** + * @param tool_choice tool choice object + * @deprecated use {@code setToolChoice(Object)} ({@link #toolChoice}) instead */ @Deprecated public void setTool_choice(Map tool_choice) { @@ -44,7 +75,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") @@ -55,6 +87,77 @@ public Map getTool_choice() { return null; } + private static List toAiTools(Map tools) { + if (tools == null) { + return null; + } + if (tools.isEmpty()) { + return new ArrayList<>(); + } + Object tool; + if (tools.size() == 1 && tools.containsKey("tool")) { + tool = tools.get("tool"); + } else if (tools.containsKey("type") && tools.containsKey("function")) { + tool = tools; + } else { + throw invalidTools("expected {\"tool\": {...}} or {\"type\": \"function\", \"function\": {...}}, got keys " + tools.keySet()); + } + AiTool aiTool = new AiTool(); + aiTool.setTool(toTool(tool)); + return new ArrayList<>(Collections.singletonList(aiTool)); + } + + @SuppressWarnings("unchecked") + private static Tool toTool(Object value) { + if (!(value instanceof Map)) { + throw invalidTools("\"tool\" must be an object"); + } + Map map = (Map) value; + requireOnlyKeys(map, "tool", "type", "function"); + if (!"function".equals(map.get("type"))) { + throw invalidTools("\"type\" must be \"function\""); + } + if (!(map.get("function") instanceof Map)) { + throw invalidTools("\"function\" must be an object"); + } + Map function = (Map) map.get("function"); + requireOnlyKeys(function, "function", "name", "description", "parameters"); + Object name = function.get("name"); + if (!(name instanceof String) || ((String) name).isEmpty()) { + throw invalidTools("\"function.name\" must be a non-empty string"); + } + Object description = function.get("description"); + if (description != null && !(description instanceof String)) { + throw invalidTools("\"function.description\" must be a string"); + } + Object parameters = function.get("parameters"); + if (parameters != null && !(parameters instanceof Map)) { + throw invalidTools("\"function.parameters\" must be an object"); + } + ToolFunction toolFunction = new ToolFunction(); + toolFunction.setName((String) name); + toolFunction.setDescription((String) description); + toolFunction.setParameters((Map) parameters); + Tool tool = new Tool(); + tool.setType("function"); + tool.setFunction(toolFunction); + return tool; + } + + private static void requireOnlyKeys(Map map, String name, String... allowed) { + List allowedKeys = Arrays.asList(allowed); + for (String key : map.keySet()) { + if (!allowedKeys.contains(key)) { + throw invalidTools("unsupported key \"" + key + "\" in \"" + name + "\""); + } + } + } + + private static IllegalArgumentException invalidTools(String reason) { + return new IllegalArgumentException("Legacy tools map cannot be converted to the API tools array (" + reason + + "); use setAiTools(List) instead"); + } + @Data public static class AiTool { private Tool tool; diff --git a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java index 75931e1c..bec23196 100644 --- a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java +++ b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java @@ -19,11 +19,6 @@ public static class AiPromptCompletionData { private Date updatedAt; private Date startedAt; private Date finishedAt; - /** - * @deprecated deprecated in the API - */ - @Deprecated - private String eta; } @Data diff --git a/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java b/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java index 9fe669bb..97685684 100644 --- a/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java +++ b/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java @@ -4,11 +4,6 @@ @Data public class ProjectAiSettings { - /** - * @deprecated deprecated in the API - */ - @Deprecated - private Long preTranslationAiPromptId; private Long editorSuggestionAiPromptId; /** * Crowdin Enterprise only diff --git a/src/test/java/com/crowdin/client/ai/AIApiTest.java b/src/test/java/com/crowdin/client/ai/AIApiTest.java index e6bf1f67..5a9ed496 100644 --- a/src/test/java/com/crowdin/client/ai/AIApiTest.java +++ b/src/test/java/com/crowdin/client/ai/AIApiTest.java @@ -595,7 +595,6 @@ public void getAiPromptCompletionStatusTest() { ResponseObject response = this.getAiApi().getPromptCompletionStatus(userId, aiPromptId, completionId); assertEquals(response.getData().getIdentifier(), completionId); assertEquals(new Date(year, month, date, hour, minutes, seconds), response.getData().getFinishedAt()); - assertEquals("1 second", response.getData().getEta()); } @Test @@ -632,7 +631,7 @@ public void promptCompletionRequestWireFormatTest() throws Exception { Map choice = new HashMap<>(); choice.put("type", "function"); request.setTool_choice(choice); - request.setTools(Collections.singletonMap("ignored", "value")); + assertThrows(IllegalArgumentException.class, () -> request.setTools(Collections.singletonMap("ignored", "value"))); com.fasterxml.jackson.databind.JsonNode json = mapper.readTree(mapper.writeValueAsString(request)); assertEquals("function", json.get("tool_choice").get("type").asText()); @@ -824,7 +823,6 @@ public void listEnterpriseAllAiProviderModelsTest() { public void getProjectAiSettingsTest() { ResponseObject response = this.getAiApi().getProjectAiSettings(projectId); ProjectAiSettings settings = response.getData(); - assertEquals(2L, settings.getPreTranslationAiPromptId()); assertEquals(5L, settings.getEditorSuggestionAiPromptId()); assertEquals(3L, settings.getAlignmentActionAiPromptId()); assertEquals(8L, settings.getQaCheckActionAiPromptId()); @@ -861,40 +859,45 @@ public void addAiProviderWithCredentialsTest() { } @Test + @SuppressWarnings("unchecked") public void aiGatewayGetRawTest() { - Map response = this.getAiApi().aiGatewayGetRaw(userId, 1L, rawGatewayPath); + Map response = (Map) this.getAiApi().aiGatewayGetRaw(userId, 1L, rawGatewayPath); assertEquals("chatcmpl-123", response.get("id")); assertEquals("chat.completion", response.get("object")); } @Test + @SuppressWarnings("unchecked") public void aiGatewayPostRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = this.getAiApi().aiGatewayPostRaw(userId, 1L, rawGatewayPath, req); + Map response = (Map) this.getAiApi().aiGatewayPostRaw(userId, 1L, rawGatewayPath, req); assertEquals("chatcmpl-123", response.get("id")); assertEquals(1, ((List) response.get("choices")).size()); } @Test + @SuppressWarnings("unchecked") public void aiGatewayPutRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = this.getAiApi().aiGatewayPutRaw(userId, 1L, rawGatewayPath, req); + Map response = (Map) this.getAiApi().aiGatewayPutRaw(userId, 1L, rawGatewayPath, req); assertEquals("gpt-5.4", response.get("model")); } @Test + @SuppressWarnings("unchecked") public void aiGatewayPatchRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = this.getAiApi().aiGatewayPatchRaw(userId, 1L, rawGatewayPath, req); + Map response = (Map) this.getAiApi().aiGatewayPatchRaw(userId, 1L, rawGatewayPath, req); assertEquals("gpt-5.4", response.get("model")); } @Test + @SuppressWarnings("unchecked") public void aiGatewayDeleteRawTest() { - Map response = this.getAiApi().aiGatewayDeleteRaw(userId, 1L, rawGatewayPath); + Map response = (Map) this.getAiApi().aiGatewayDeleteRaw(userId, 1L, rawGatewayPath); assertEquals("chatcmpl-123", response.get("id")); } 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..f3f8e6d7 --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java @@ -0,0 +1,182 @@ +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 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.aiGatewayGetRaw(7L, 3L, "v1/object"); + assertEquals("chatcmpl-1", object.get("id")); + assertEquals(1, ((List) object.get("choices")).size()); + + List array = (List) api.aiGatewayGetRaw(7L, 3L, "v1/array"); + assertEquals(2, array.size()); + assertEquals("model-b", ((Map) array.get(1)).get("id")); + + assertEquals("plain", api.aiGatewayGetRaw(7L, 3L, "v1/string")); + assertEquals(42, ((Number) api.aiGatewayGetRaw(7L, 3L, "v1/integer")).intValue()); + assertEquals(0.75, ((Number) api.aiGatewayGetRaw(7L, 3L, "v1/decimal")).doubleValue()); + assertEquals(Boolean.TRUE, api.aiGatewayGetRaw(7L, 3L, "v1/boolean")); + assertNull(api.aiGatewayGetRaw(7L, 3L, "v1/jsonnull")); + } + + @Test + void emptyBodyIsNullTest() { + assertNull(api.aiGatewayGetRaw(7L, 3L, "v1/empty")); + assertNull(api.aiGatewayDeleteRaw(null, 3L, "v1/empty")); + assertNull(api.aiGatewayPostRaw(7L, 3L, "v1/blank", Collections.singletonMap("model", "m"))); + } + + @Test + void nonJsonBodyFailsWithHttpExceptionTest() { + HttpException e = assertThrows(HttpException.class, () -> api.aiGatewayGetRaw(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.aiGatewayPostRaw(7L, 3L, "v1/error", Collections.singletonMap("model", "m"))); + assertEquals("provider rejected the request", e.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.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.aiGatewayGetRaw(7L, 3L, "v1/object"); + api.aiGatewayPostRaw(7L, 3L, "v1/object", body); + api.aiGatewayPutRaw(7L, 3L, "v1/object", body); + api.aiGatewayPatchRaw(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..a12691ec --- /dev/null +++ b/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java @@ -0,0 +1,128 @@ +package com.crowdin.client.ai.model; + +import com.crowdin.client.core.http.impl.json.JacksonJsonTransformer; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings("deprecation") +class AiPromptCompletionRequestToolsTest { + + private final JacksonJsonTransformer jsonTransformer = new JacksonJsonTransformer(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void legacyToolsArrayEntryIsConvertedTest() { + Map legacy = map("tool", map("type", "function", + "function", map("name", "lookup", "description", "Looks up a term", "parameters", map("type", "object")))); + + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setTools(legacy); + + assertSame(legacy, request.getTools()); + assertEquals(1, request.getAiTools().size()); + AiPromptCompletionRequest.ToolFunction function = request.getAiTools().get(0).getTool().getFunction(); + assertEquals("function", request.getAiTools().get(0).getTool().getType()); + assertEquals("lookup", function.getName()); + assertEquals("Looks up a term", function.getDescription()); + assertEquals(map("type", "object"), function.getParameters()); + assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"," + + "\"description\":\"Looks up a term\",\"parameters\":{\"type\":\"object\"}}}}]}", jsonTransformer.convert(request)); + } + + @Test + void legacyToolObjectIsConvertedTest() { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setTools(map("type", "function", "function", map("name", "lookup"))); + + assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"}}}]}", + jsonTransformer.convert(request)); + } + + @Test + void legacyNullAndEmptyToolsTest() { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setTools(Collections.emptyMap()); + assertTrue(request.getAiTools().isEmpty()); + assertEquals("{\"tools\":[]}", jsonTransformer.convert(request)); + + request.setTools(null); + assertNull(request.getTools()); + assertNull(request.getAiTools()); + assertEquals("{}", jsonTransformer.convert(request)); + } + + @Test + void invalidLegacyToolsAreRejectedTest() { + List> invalid = Arrays.asList( + map("name", "lookup"), + map("tool", "lookup"), + map("tool", map("type", "function", "function", map("name", "a")), "extra", 1), + map("tool", map("type", "retrieval", "function", map("name", "a"))), + map("tool", map("type", "function")), + map("tool", map("type", "function", "function", "lookup")), + map("tool", map("type", "function", "function", map("description", "no name"))), + map("tool", map("type", "function", "function", map("name", ""))), + map("tool", map("type", "function", "function", map("name", 5))), + map("tool", map("type", "function", "function", map("name", "a", "description", 1))), + map("tool", map("type", "function", "function", map("name", "a", "parameters", "{}"))), + map("tool", map("type", "function", "function", map("name", "a", "strict", true))), + map("tool", map("type", "function", "function", map("name", "a"), "id", "x")), + map("type", "function", "function", map("name", "a"), "tool", map()) + ); + for (Map tools : invalid) { + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + AiPromptCompletionRequest.AiTool existing = new AiPromptCompletionRequest.AiTool(); + List existingTools = Collections.singletonList(existing); + request.setAiTools(existingTools); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> request.setTools(tools), tools.toString()); + assertTrue(e.getMessage().contains("setAiTools"), e.getMessage()); + assertSame(existingTools, request.getAiTools(), "state must not change: " + tools); + assertNull(request.getTools()); + } + } + + @Test + void newToolsApiIsUnaffectedTest() { + AiPromptCompletionRequest.ToolFunction function = new AiPromptCompletionRequest.ToolFunction(); + function.setName("lookup"); + AiPromptCompletionRequest.Tool tool = new AiPromptCompletionRequest.Tool(); + tool.setType("function"); + tool.setFunction(function); + AiPromptCompletionRequest.AiTool aiTool = new AiPromptCompletionRequest.AiTool(); + aiTool.setTool(tool); + AiPromptCompletionRequest request = new AiPromptCompletionRequest(); + request.setAiTools(Collections.singletonList(aiTool)); + request.setToolChoice("auto"); + + assertNull(request.getTools()); + assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"}}}],\"tool_choice\":\"auto\"}", + jsonTransformer.convert(request)); + } + + private static Map map(Object... keyValues) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + map.put((String) keyValues[i], keyValues[i + 1]); + } + return map; + } + + @SneakyThrows + private void assertJsonEquals(String expected, String actual) { + assertEquals(objectMapper.readTree(expected), objectMapper.readTree(actual), actual); + } +} diff --git a/src/test/resources/api/ai/projectAiSettingsResponse.json b/src/test/resources/api/ai/projectAiSettingsResponse.json index 0b92b057..adb0b5ca 100644 --- a/src/test/resources/api/ai/projectAiSettingsResponse.json +++ b/src/test/resources/api/ai/projectAiSettingsResponse.json @@ -1,6 +1,5 @@ { "data": { - "preTranslationAiPromptId": 2, "editorSuggestionAiPromptId": 5, "alignmentActionAiPromptId": 3, "qaCheckActionAiPromptId": 8, diff --git a/src/test/resources/api/ai/promptCompletionResponse.json b/src/test/resources/api/ai/promptCompletionResponse.json index adef9de3..19af55b3 100644 --- a/src/test/resources/api/ai/promptCompletionResponse.json +++ b/src/test/resources/api/ai/promptCompletionResponse.json @@ -9,7 +9,6 @@ "createdAt": "2019-09-23T11:26:54+00:00", "updatedAt": "2019-09-23T11:26:54+00:00", "startedAt": "2019-09-23T11:26:54+00:00", - "finishedAt": "2019-09-23T11:26:54+00:00", - "eta": "1 second" + "finishedAt": "2019-09-23T11:26:54+00:00" } } \ No newline at end of file From 643b7bde1bd21c3335b01605eecc841f39c3da97 Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Sun, 1 Nov 2026 21:04:09 -0800 Subject: [PATCH 4/5] fix(ai): keep 1.37.0 AI API compatible, add aiGateway*Json methods SDK 1.37.0 was released with #424, so this follow-up must not change its public API: - keep AiPromptCompletionData.eta and ProjectAiSettings.preTranslationAiPromptId as @Deprecated (both are deprecated in the API) instead of removing them - keep the five aiGateway*Raw methods with their released Map signatures and behaviour (JSON object responses only) - add aiGatewayGetJson/PostJson/PutJson/PatchJson/DeleteJson returning any JSON value (Map, List, String, Number, Boolean or null; empty body is null); they have new names, so no overload ambiguity is introduced The credentials toString, setTools(Map) and learningRateMultiplier fixes are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/com/crowdin/client/ai/AIApi.java | 121 ++++++++++++++++-- .../ai/model/AiPromptCompletionResponse.java | 5 + .../client/ai/model/ProjectAiSettings.java | 5 + .../java/com/crowdin/client/ai/AIApiTest.java | 34 +++-- .../client/ai/AiGatewayRawHttpTest.java | 50 +++++--- .../api/ai/projectAiSettingsResponse.json | 1 + .../api/ai/promptCompletionResponse.json | 3 +- 7 files changed, 183 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/crowdin/client/ai/AIApi.java b/src/main/java/com/crowdin/client/ai/AIApi.java index 6431587a..21e2b283 100644 --- a/src/main/java/com/crowdin/client/ai/AIApi.java +++ b/src/main/java/com/crowdin/client/ai/AIApi.java @@ -931,6 +931,109 @@ 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 + * @param path downstream endpoint path (e.g. models) + * @return raw provider response body + * @see + */ + @SuppressWarnings("unchecked") + public Map aiGatewayGetRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + return this.httpClient.get(url, new HttpRequestConfig(), Map.class); + } + + /** + * 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 + * @param path downstream endpoint path (e.g. chat/completions) + * @param request raw provider request body + * @return raw provider response body + * @see + */ + @SuppressWarnings("unchecked") + public Map aiGatewayPostRaw(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.httpClient.post(url, request, new HttpRequestConfig(), Map.class); + } + + /** + * 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 + * @param path downstream endpoint path + * @param request raw provider request body + * @return raw provider response body + * @see + */ + @SuppressWarnings("unchecked") + public Map aiGatewayPutRaw(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.httpClient.put(url, request, new HttpRequestConfig(), Map.class); + } + + /** + * 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 + * @param path downstream endpoint path + * @param request raw provider request body + * @return raw provider response body + * @see + */ + @SuppressWarnings("unchecked") + public Map aiGatewayPatchRaw(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.httpClient.patch(url, request, new HttpRequestConfig(), Map.class); + } + + /** + * 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 + * @param path downstream endpoint path + * @return raw provider response body + * @see + */ + @SuppressWarnings("unchecked") + public Map aiGatewayDeleteRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + String url = getAIPath(userId, "ai/providers/" + aiProviderId + "/gateway/" + path); + 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 @@ -947,13 +1050,13 @@ public ResponseObject> aiGatewayPatch(final Long userId, fin *

  • Enterprise API Documentation
  • * */ - public Object aiGatewayGetRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + 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 raw provider response body. + * 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 @@ -971,13 +1074,13 @@ public Object aiGatewayGetRaw(final Long userId, final Long aiProviderId, final *

  • Enterprise API Documentation
  • * */ - public Object aiGatewayPostRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + 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 raw provider response body. + * 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 @@ -995,13 +1098,13 @@ public Object aiGatewayPostRaw(final Long userId, final Long aiProviderId, final *

  • Enterprise API Documentation
  • * */ - public Object aiGatewayPutRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + 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 raw provider response body. + * 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 @@ -1019,13 +1122,13 @@ public Object aiGatewayPutRaw(final Long userId, final Long aiProviderId, final *

  • Enterprise API Documentation
  • * */ - public Object aiGatewayPatchRaw(final Long userId, final Long aiProviderId, final String path, final Map request) throws HttpException, HttpBadRequestException { + 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 raw provider response body. + * 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 @@ -1042,7 +1145,7 @@ public Object aiGatewayPatchRaw(final Long userId, final Long aiProviderId, fina *

  • Enterprise API Documentation
  • * */ - public Object aiGatewayDeleteRaw(final Long userId, final Long aiProviderId, final String path) throws HttpException, HttpBadRequestException { + 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)); } diff --git a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java index bec23196..75931e1c 100644 --- a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java +++ b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionResponse.java @@ -19,6 +19,11 @@ public static class AiPromptCompletionData { private Date updatedAt; private Date startedAt; private Date finishedAt; + /** + * @deprecated deprecated in the API + */ + @Deprecated + private String eta; } @Data diff --git a/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java b/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java index 97685684..9fe669bb 100644 --- a/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java +++ b/src/main/java/com/crowdin/client/ai/model/ProjectAiSettings.java @@ -4,6 +4,11 @@ @Data public class ProjectAiSettings { + /** + * @deprecated deprecated in the API + */ + @Deprecated + private Long preTranslationAiPromptId; private Long editorSuggestionAiPromptId; /** * Crowdin Enterprise only diff --git a/src/test/java/com/crowdin/client/ai/AIApiTest.java b/src/test/java/com/crowdin/client/ai/AIApiTest.java index 5a9ed496..c9b84a18 100644 --- a/src/test/java/com/crowdin/client/ai/AIApiTest.java +++ b/src/test/java/com/crowdin/client/ai/AIApiTest.java @@ -595,6 +595,7 @@ public void getAiPromptCompletionStatusTest() { ResponseObject response = this.getAiApi().getPromptCompletionStatus(userId, aiPromptId, completionId); assertEquals(response.getData().getIdentifier(), completionId); assertEquals(new Date(year, month, date, hour, minutes, seconds), response.getData().getFinishedAt()); + assertEquals("1 second", response.getData().getEta()); } @Test @@ -823,6 +824,7 @@ public void listEnterpriseAllAiProviderModelsTest() { public void getProjectAiSettingsTest() { ResponseObject response = this.getAiApi().getProjectAiSettings(projectId); ProjectAiSettings settings = response.getData(); + assertEquals(2L, settings.getPreTranslationAiPromptId()); assertEquals(5L, settings.getEditorSuggestionAiPromptId()); assertEquals(3L, settings.getAlignmentActionAiPromptId()); assertEquals(8L, settings.getQaCheckActionAiPromptId()); @@ -859,48 +861,60 @@ public void addAiProviderWithCredentialsTest() { } @Test - @SuppressWarnings("unchecked") public void aiGatewayGetRawTest() { - Map response = (Map) this.getAiApi().aiGatewayGetRaw(userId, 1L, rawGatewayPath); + Map response = this.getAiApi().aiGatewayGetRaw(userId, 1L, rawGatewayPath); assertEquals("chatcmpl-123", response.get("id")); assertEquals("chat.completion", response.get("object")); } @Test - @SuppressWarnings("unchecked") public void aiGatewayPostRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = (Map) this.getAiApi().aiGatewayPostRaw(userId, 1L, rawGatewayPath, req); + Map response = this.getAiApi().aiGatewayPostRaw(userId, 1L, rawGatewayPath, req); assertEquals("chatcmpl-123", response.get("id")); assertEquals(1, ((List) response.get("choices")).size()); } @Test - @SuppressWarnings("unchecked") public void aiGatewayPutRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = (Map) this.getAiApi().aiGatewayPutRaw(userId, 1L, rawGatewayPath, req); + Map response = this.getAiApi().aiGatewayPutRaw(userId, 1L, rawGatewayPath, req); assertEquals("gpt-5.4", response.get("model")); } @Test - @SuppressWarnings("unchecked") public void aiGatewayPatchRawTest() { Map req = new HashMap<>(); req.put("model", "string"); - Map response = (Map) this.getAiApi().aiGatewayPatchRaw(userId, 1L, rawGatewayPath, req); + Map response = this.getAiApi().aiGatewayPatchRaw(userId, 1L, rawGatewayPath, req); assertEquals("gpt-5.4", response.get("model")); } @Test - @SuppressWarnings("unchecked") public void aiGatewayDeleteRawTest() { - Map response = (Map) this.getAiApi().aiGatewayDeleteRaw(userId, 1L, rawGatewayPath); + Map response = this.getAiApi().aiGatewayDeleteRaw(userId, 1L, rawGatewayPath); 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 index f3f8e6d7..bd8a44c5 100644 --- a/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java +++ b/src/test/java/com/crowdin/client/ai/AiGatewayRawHttpTest.java @@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Exercises the AI gateway methods through the real {@code ApacheHttpClient} and {@code JacksonJsonTransformer} + * 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 { @@ -88,47 +88,65 @@ void stopServer() { @Test @SuppressWarnings("unchecked") void jsonResponseTypesTest() { - Map object = (Map) api.aiGatewayGetRaw(7L, 3L, "v1/object"); + 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.aiGatewayGetRaw(7L, 3L, "v1/array"); + 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.aiGatewayGetRaw(7L, 3L, "v1/string")); - assertEquals(42, ((Number) api.aiGatewayGetRaw(7L, 3L, "v1/integer")).intValue()); - assertEquals(0.75, ((Number) api.aiGatewayGetRaw(7L, 3L, "v1/decimal")).doubleValue()); - assertEquals(Boolean.TRUE, api.aiGatewayGetRaw(7L, 3L, "v1/boolean")); - assertNull(api.aiGatewayGetRaw(7L, 3L, "v1/jsonnull")); + 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.aiGatewayGetRaw(7L, 3L, "v1/empty")); - assertNull(api.aiGatewayDeleteRaw(null, 3L, "v1/empty")); - assertNull(api.aiGatewayPostRaw(7L, 3L, "v1/blank", Collections.singletonMap("model", "m"))); + 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.aiGatewayGetRaw(7L, 3L, "v1/text")); + 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.aiGatewayPostRaw(7L, 3L, "v1/error", Collections.singletonMap("model", "m"))); + 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")); } @@ -139,10 +157,10 @@ void requestsUseDocumentedMethodsAndPathsTest() { body.put("model", "gpt"); body.put("messages", Collections.singletonList(Collections.singletonMap("role", "user"))); - api.aiGatewayGetRaw(7L, 3L, "v1/object"); - api.aiGatewayPostRaw(7L, 3L, "v1/object", body); + api.aiGatewayGetJson(7L, 3L, "v1/object"); + api.aiGatewayPostJson(7L, 3L, "v1/object", body); api.aiGatewayPutRaw(7L, 3L, "v1/object", body); - api.aiGatewayPatchRaw(null, 3L, "v1/object", body); + api.aiGatewayPatchJson(null, 3L, "v1/object", body); api.aiGatewayDeleteRaw(null, 3L, "v1/object"); List expected = Arrays.asList( diff --git a/src/test/resources/api/ai/projectAiSettingsResponse.json b/src/test/resources/api/ai/projectAiSettingsResponse.json index adb0b5ca..0b92b057 100644 --- a/src/test/resources/api/ai/projectAiSettingsResponse.json +++ b/src/test/resources/api/ai/projectAiSettingsResponse.json @@ -1,5 +1,6 @@ { "data": { + "preTranslationAiPromptId": 2, "editorSuggestionAiPromptId": 5, "alignmentActionAiPromptId": 3, "qaCheckActionAiPromptId": 8, diff --git a/src/test/resources/api/ai/promptCompletionResponse.json b/src/test/resources/api/ai/promptCompletionResponse.json index 19af55b3..adef9de3 100644 --- a/src/test/resources/api/ai/promptCompletionResponse.json +++ b/src/test/resources/api/ai/promptCompletionResponse.json @@ -9,6 +9,7 @@ "createdAt": "2019-09-23T11:26:54+00:00", "updatedAt": "2019-09-23T11:26:54+00:00", "startedAt": "2019-09-23T11:26:54+00:00", - "finishedAt": "2019-09-23T11:26:54+00:00" + "finishedAt": "2019-09-23T11:26:54+00:00", + "eta": "1 second" } } \ No newline at end of file From c3324566130bc879988a6432039e62973d53ad14 Mon Sep 17 00:00:00 2001 From: Kateryna Oblakevych Date: Tue, 3 Nov 2026 21:32:17 -0800 Subject: [PATCH 5/5] refactor(ai): restore the 1.37.0 setTools(Map) behavior Remove the legacy tools map conversion and its helpers (toAiTools, toTool, requireOnlyKeys, invalidTools). setTools(Map) / getTools() are Lombok accessors again: the map is stored but not sent, exactly as in 1.37.0, and the documented tools array is sent through setAiTools(List). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai/model/AiPromptCompletionRequest.java | 100 +------------ .../java/com/crowdin/client/ai/AIApiTest.java | 2 +- .../AiPromptCompletionRequestToolsTest.java | 131 ++++++++---------- 3 files changed, 62 insertions(+), 171 deletions(-) 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 7c05f864..6cfa55d6 100644 --- a/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java +++ b/src/main/java/com/crowdin/client/ai/model/AiPromptCompletionRequest.java @@ -4,9 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -15,10 +12,10 @@ public class AiPromptCompletionRequest { private AiPromptResource resources; /** - * Legacy map view of the tools, as passed to {@code setTools(Map)}. It is never sent as is; the deprecated setter - * converts it into {@link #aiTools}. + * 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 {@link #aiTools} instead + * @deprecated the API expects an array of tools; use {@code setAiTools(List)} ({@link #aiTools}) instead */ @Deprecated @JsonIgnore @@ -44,26 +41,6 @@ public class AiPromptCompletionRequest { @JsonProperty("tool_choice") private Object toolChoice; - /** - * Legacy setter for the tools. The map is converted into a single {@link #aiTools} entry when it has one of the - * documented shapes: - *
      - *
    • a tools array entry: {@code {"tool": {"type": "function", "function": {...}}}}
    • - *
    • the tool object itself: {@code {"type": "function", "function": {...}}}
    • - *
    - * where {@code function} contains {@code name} (required, string), and optionally {@code description} (string) - * and {@code parameters} (object). An empty map sets an empty tools array and {@code null} clears the tools. - * - * @param tools legacy tools map - * @throws IllegalArgumentException if the map has any other structure; nothing is changed in that case - * @deprecated the API expects an array of tools; use {@code setAiTools(List)} ({@link #aiTools}) instead - */ - @Deprecated - public void setTools(Map tools) { - this.aiTools = toAiTools(tools); - this.tools = tools; - } - /** * @param tool_choice tool choice object * @deprecated use {@code setToolChoice(Object)} ({@link #toolChoice}) instead @@ -87,77 +64,6 @@ public Map getTool_choice() { return null; } - private static List toAiTools(Map tools) { - if (tools == null) { - return null; - } - if (tools.isEmpty()) { - return new ArrayList<>(); - } - Object tool; - if (tools.size() == 1 && tools.containsKey("tool")) { - tool = tools.get("tool"); - } else if (tools.containsKey("type") && tools.containsKey("function")) { - tool = tools; - } else { - throw invalidTools("expected {\"tool\": {...}} or {\"type\": \"function\", \"function\": {...}}, got keys " + tools.keySet()); - } - AiTool aiTool = new AiTool(); - aiTool.setTool(toTool(tool)); - return new ArrayList<>(Collections.singletonList(aiTool)); - } - - @SuppressWarnings("unchecked") - private static Tool toTool(Object value) { - if (!(value instanceof Map)) { - throw invalidTools("\"tool\" must be an object"); - } - Map map = (Map) value; - requireOnlyKeys(map, "tool", "type", "function"); - if (!"function".equals(map.get("type"))) { - throw invalidTools("\"type\" must be \"function\""); - } - if (!(map.get("function") instanceof Map)) { - throw invalidTools("\"function\" must be an object"); - } - Map function = (Map) map.get("function"); - requireOnlyKeys(function, "function", "name", "description", "parameters"); - Object name = function.get("name"); - if (!(name instanceof String) || ((String) name).isEmpty()) { - throw invalidTools("\"function.name\" must be a non-empty string"); - } - Object description = function.get("description"); - if (description != null && !(description instanceof String)) { - throw invalidTools("\"function.description\" must be a string"); - } - Object parameters = function.get("parameters"); - if (parameters != null && !(parameters instanceof Map)) { - throw invalidTools("\"function.parameters\" must be an object"); - } - ToolFunction toolFunction = new ToolFunction(); - toolFunction.setName((String) name); - toolFunction.setDescription((String) description); - toolFunction.setParameters((Map) parameters); - Tool tool = new Tool(); - tool.setType("function"); - tool.setFunction(toolFunction); - return tool; - } - - private static void requireOnlyKeys(Map map, String name, String... allowed) { - List allowedKeys = Arrays.asList(allowed); - for (String key : map.keySet()) { - if (!allowedKeys.contains(key)) { - throw invalidTools("unsupported key \"" + key + "\" in \"" + name + "\""); - } - } - } - - private static IllegalArgumentException invalidTools(String reason) { - return new IllegalArgumentException("Legacy tools map cannot be converted to the API tools array (" + reason - + "); use setAiTools(List) instead"); - } - @Data public static class AiTool { private Tool tool; diff --git a/src/test/java/com/crowdin/client/ai/AIApiTest.java b/src/test/java/com/crowdin/client/ai/AIApiTest.java index c9b84a18..c5452d52 100644 --- a/src/test/java/com/crowdin/client/ai/AIApiTest.java +++ b/src/test/java/com/crowdin/client/ai/AIApiTest.java @@ -632,7 +632,7 @@ public void promptCompletionRequestWireFormatTest() throws Exception { Map choice = new HashMap<>(); choice.put("type", "function"); request.setTool_choice(choice); - assertThrows(IllegalArgumentException.class, () -> request.setTools(Collections.singletonMap("ignored", "value"))); + request.setTools(Collections.singletonMap("ignored", "value")); com.fasterxml.jackson.databind.JsonNode json = mapper.readTree(mapper.writeValueAsString(request)); assertEquals("function", json.get("tool_choice").get("type").asText()); diff --git a/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java b/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java index a12691ec..1a608cb5 100644 --- a/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java +++ b/src/test/java/com/crowdin/client/ai/model/AiPromptCompletionRequestToolsTest.java @@ -1,22 +1,26 @@ 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.Arrays; 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.assertThrows; 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 { @@ -24,105 +28,86 @@ class AiPromptCompletionRequestToolsTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Test - void legacyToolsArrayEntryIsConvertedTest() { - Map legacy = map("tool", map("type", "function", - "function", map("name", "lookup", "description", "Looks up a term", "parameters", map("type", "object")))); - + void legacyToolsMapIsStoredButNotSentTest() { + Map legacy = new LinkedHashMap<>(); + legacy.put("tool", Collections.singletonMap("type", "function")); AiPromptCompletionRequest request = new AiPromptCompletionRequest(); request.setTools(legacy); assertSame(legacy, request.getTools()); - assertEquals(1, request.getAiTools().size()); - AiPromptCompletionRequest.ToolFunction function = request.getAiTools().get(0).getTool().getFunction(); - assertEquals("function", request.getAiTools().get(0).getTool().getType()); - assertEquals("lookup", function.getName()); - assertEquals("Looks up a term", function.getDescription()); - assertEquals(map("type", "object"), function.getParameters()); - assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"," - + "\"description\":\"Looks up a term\",\"parameters\":{\"type\":\"object\"}}}}]}", jsonTransformer.convert(request)); + 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 legacyToolObjectIsConvertedTest() { + void anyLegacyMapShapeIsAcceptedWithoutValidationTest() { AiPromptCompletionRequest request = new AiPromptCompletionRequest(); - request.setTools(map("type", "function", "function", map("name", "lookup"))); - - assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"}}}]}", - jsonTransformer.convert(request)); + Map unexpected = Collections.singletonMap("ignored", "value"); + request.setTools(unexpected); + assertSame(unexpected, request.getTools()); + assertEquals("{}", jsonTransformer.convert(request)); } @Test - void legacyNullAndEmptyToolsTest() { + void aiToolsAreSentAsTheDocumentedArrayTest() { AiPromptCompletionRequest request = new AiPromptCompletionRequest(); - request.setTools(Collections.emptyMap()); - assertTrue(request.getAiTools().isEmpty()); - assertEquals("{\"tools\":[]}", jsonTransformer.convert(request)); + request.setAiTools(Collections.singletonList(tool("get_weather", "Weather", Collections.singletonMap("type", "object")))); - request.setTools(null); - assertNull(request.getTools()); - assertNull(request.getAiTools()); - assertEquals("{}", jsonTransformer.convert(request)); + 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 invalidLegacyToolsAreRejectedTest() { - List> invalid = Arrays.asList( - map("name", "lookup"), - map("tool", "lookup"), - map("tool", map("type", "function", "function", map("name", "a")), "extra", 1), - map("tool", map("type", "retrieval", "function", map("name", "a"))), - map("tool", map("type", "function")), - map("tool", map("type", "function", "function", "lookup")), - map("tool", map("type", "function", "function", map("description", "no name"))), - map("tool", map("type", "function", "function", map("name", ""))), - map("tool", map("type", "function", "function", map("name", 5))), - map("tool", map("type", "function", "function", map("name", "a", "description", 1))), - map("tool", map("type", "function", "function", map("name", "a", "parameters", "{}"))), - map("tool", map("type", "function", "function", map("name", "a", "strict", true))), - map("tool", map("type", "function", "function", map("name", "a"), "id", "x")), - map("type", "function", "function", map("name", "a"), "tool", map()) - ); - for (Map tools : invalid) { - AiPromptCompletionRequest request = new AiPromptCompletionRequest(); - AiPromptCompletionRequest.AiTool existing = new AiPromptCompletionRequest.AiTool(); - List existingTools = Collections.singletonList(existing); - request.setAiTools(existingTools); - - IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> request.setTools(tools), tools.toString()); - assertTrue(e.getMessage().contains("setAiTools"), e.getMessage()); - assertSame(existingTools, request.getAiTools(), "state must not change: " + tools); - assertNull(request.getTools()); - } + 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 newToolsApiIsUnaffectedTest() { + 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("lookup"); + 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); - AiPromptCompletionRequest request = new AiPromptCompletionRequest(); - request.setAiTools(Collections.singletonList(aiTool)); - request.setToolChoice("auto"); - - assertNull(request.getTools()); - assertJsonEquals("{\"tools\":[{\"tool\":{\"type\":\"function\",\"function\":{\"name\":\"lookup\"}}}],\"tool_choice\":\"auto\"}", - jsonTransformer.convert(request)); - } - - private static Map map(Object... keyValues) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < keyValues.length; i += 2) { - map.put((String) keyValues[i], keyValues[i + 1]); - } - return map; + return aiTool; } @SneakyThrows private void assertJsonEquals(String expected, String actual) { - assertEquals(objectMapper.readTree(expected), objectMapper.readTree(actual), actual); + JsonNode e = objectMapper.readTree(expected); + JsonNode a = objectMapper.readTree(actual); + assertEquals(e, a, actual); } }