diff --git a/application/pom.xml b/application/pom.xml index 9489c627a1..60186c3d3b 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -395,11 +395,7 @@ org.thingsboard.langchain4j - langchain4j-google-ai-gemini - - - org.thingsboard.langchain4j - langchain4j-vertex-ai-gemini + langchain4j-google-genai org.thingsboard.langchain4j @@ -415,13 +411,7 @@ org.thingsboard.langchain4j - langchain4j-github-models - - - com.azure - azure-core-test - - + langchain4j-open-ai-official org.thingsboard.langchain4j diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 8a113fb424..998ca1cfa5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION; @@ -77,9 +79,13 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); + SecurityUser currentUser = getCurrentUser(); + if (alarmComment.getId() != null) { + checkUserPermission(alarmComment, alarmId, "edit", currentUser); + } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); - return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); + return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", @@ -93,7 +99,11 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); - tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); + SecurityUser currentUser = getCurrentUser(); + if (!currentUser.isTenantAdmin()) { + checkUserPermission(alarmComment, alarmId, "delete", currentUser); + } + tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Get Alarm comments (getAlarmComments)", @@ -120,4 +130,12 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException { + AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); + if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { + throw new ThingsboardException("User is not allowed to " + operation + " other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 15be6f3734..a2bc29104b 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -15,14 +15,8 @@ */ package org.thingsboard.server.service.ai; -import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.Content; -import dev.langchain4j.data.message.TextContent; -import dev.langchain4j.data.message.UserMessage; -import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -31,9 +25,6 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; -import java.util.List; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor class AiChatModelServiceImpl implements AiChatModelService { @@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService { } catch (Throwable t) { return FluentFuture.from(Futures.immediateFailedFuture(t)); } - if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { - chatRequest = prepareGithubChatRequest(chatRequest); - } return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); } - private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) { - List messages = chatRequest.messages().stream() - .map(this::prepareUserMessage) - .collect(Collectors.toList()); - - return ChatRequest.builder() - .messages(messages) - .responseFormat(chatRequest.responseFormat()) - .build(); - } - - private ChatMessage prepareUserMessage(ChatMessage message) { - if (message instanceof UserMessage userMessage) { - List newContents = userMessage.contents().stream() - .map(this::prepareContent) - .collect(Collectors.toList()); - - return UserMessage.from(newContents); - } - return message; - } - - private Content prepareContent(Content content) { - if (content instanceof TextContent txt) { - return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text()))); - } - return content; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 8b569f052c..3bb39c3671 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -15,26 +15,18 @@ */ package org.thingsboard.server.service.ai; -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.api.gax.retrying.RetrySettings; +import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; -import com.google.cloud.vertexai.Transport; -import com.google.cloud.vertexai.VertexAI; -import com.google.cloud.vertexai.api.GenerationConfig; -import com.google.cloud.vertexai.api.PredictionServiceClient; -import com.google.cloud.vertexai.api.PredictionServiceSettings; -import com.google.cloud.vertexai.generativeai.GenerativeModel; import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequestParameters; -import dev.langchain4j.model.github.GitHubModelsChatModel; -import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.google.genai.GoogleGenAiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; -import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.common.util.SsrfProtectionValidator; @@ -50,7 +42,6 @@ import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; -import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -107,7 +98,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { - return GoogleAiGeminiChatModel.builder() + return GoogleGenAiChatModel.builder() .apiKey(chatModelConfig.providerConfig().apiKey()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -123,84 +114,28 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { - GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); - - // construct service account credentials using service account key JSON - ServiceAccountCredentials serviceAccountCredentials; + GoogleCredentials credentials; try { - serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); + credentials = ServiceAccountCredentials + .fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8))) + .createScoped("https://www.googleapis.com/auth/cloud-platform"); } catch (IOException e) { throw new RuntimeException("Failed to parse service account key JSON", e); } - - PredictionServiceSettings predictionServiceClientSettings; - try { - // create prediction service settings for REST transport with service account key credentials - PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); - - // get the retry settings that control request timeout for generateContent RPC - RetrySettings.Builder retrySettings = settingsBuilder - .generateContentSettings() - .getRetrySettings() - .toBuilder(); - - // set request timeout from model config - if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); - } - - // set updated retry settings - settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); - - // build the client settings - predictionServiceClientSettings = settingsBuilder.build(); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client settings", e); - } - - // construct Vertex AI instance - var vertexAI = new VertexAI.Builder() - .setProjectId(providerConfig.projectId()) - .setLocation(providerConfig.location()) - .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) - .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + return GoogleGenAiChatModel.builder() + .projectId(chatModelConfig.providerConfig().projectId()) + .location(chatModelConfig.providerConfig().location()) + .googleCredentials(credentials) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); - - // map model config to generation config - var generationConfigBuilder = GenerationConfig.newBuilder(); - if (chatModelConfig.temperature() != null) { - generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); - } - if (chatModelConfig.topP() != null) { - generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); - } - if (chatModelConfig.topK() != null) { - generationConfigBuilder.setTopK(chatModelConfig.topK()); - } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); - } - if (chatModelConfig.presencePenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); - } - if (chatModelConfig.maxOutputTokens() != null) { - generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); - } - var generationConfig = generationConfigBuilder.build(); - - // construct generative model instance - var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); - - return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); - } - - private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { - try { - return PredictionServiceClient.create(settings); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client", e); - } } @Override @@ -262,14 +197,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { - return GitHubModelsChatModel.builder() - .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + return OpenAiOfficialChatModel.builder() + .isGitHubModels(true) + .strictJsonSchema(true) + .apiKey(chatModelConfig.providerConfig().personalAccessToken()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) .topP(chatModelConfig.topP()) .frequencyPenalty(chatModelConfig.frequencyPenalty()) .presencePenalty(chatModelConfig.presencePenalty()) - .maxTokens(chatModelConfig.maxOutputTokens()) + .maxCompletionTokens(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) .maxRetries(chatModelConfig.maxRetries()) .build(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 519601c30a..2489c3a091 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -464,10 +464,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null; + void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index e79510decf..444cb904f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -182,9 +182,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo callback.onSuccess(); } else if (nfMsg.hasFromDeviceRpcResponse()) { TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); - RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbDeviceRpcService.processRpcResponseFromDevice(response); callback.onSuccess(); } else if (nfMsg.getQueueUpdateMsgsCount() > 0) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ca450417dd..aaf182905e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1187,6 +1187,8 @@ transport: timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" # Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property) api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java index 31da96b7b4..50707e4c38 100644 --- a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java +++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java @@ -100,7 +100,7 @@ public class AlarmCommentApiClientTest extends AbstractApiClientTest { .filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId)) .findFirst() .get(); - assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText()); + assertEquals("Comment was deleted by user " + clientTenantAdmin.getEmail(), deletedComment.getComment().get("text").asText()); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 055e1e19a0..54a6f30213 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -229,6 +229,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; + protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; @@ -268,6 +269,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected CustomerId differentTenantCustomerId; protected UserId customerUserId; + protected UserId secondCustomerUserId; protected UserId differentCustomerUserId; protected UserId differentTenantCustomerUserId; @@ -393,9 +395,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { customerUser.setCustomerId(savedCustomer.getId()); customerUser.setEmail(CUSTOMER_USER_EMAIL); - customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); + customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD); customerUserId = customerUser.getId(); + User secondCustomerUser = new User(); + secondCustomerUser.setAuthority(Authority.CUSTOMER_USER); + secondCustomerUser.setTenantId(tenantId); + secondCustomerUser.setCustomerId(customerId); + secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL); + secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD); + secondCustomerUserId = secondCustomerUser.getId(); + resetTokens(); log.debug("Executed web test setup"); @@ -494,6 +504,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); } + protected void loginSecondCustomerUser() throws Exception { + login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); + } + protected void loginUser(String userName, String password) throws Exception { login(userName, password); } @@ -608,6 +622,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return savedUser; } + protected User createUserAndActivate(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + JsonNode activateRequest = getActivateRequest(password); + doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk()); + return savedUser; + } + protected User createUser(User user, String password) throws Exception { User savedUser = doPost("/api/user", user, User.class); JsonNode activateRequest = getActivateRequest(password); diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 3bb5fd5647..99e98e6546 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -161,6 +161,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); } + @Test + public void testEditOthersAlarmCommentIsProhibited() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite")); + alarmComment.setComment(newComment); + + loginSecondCustomerUser(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + + loginTenantAdmin(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + } + @Test public void testUpdateAlarmViaDifferentTenant() throws Exception { loginTenantAdmin(); @@ -218,6 +237,32 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } + @Test + public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + loginSecondCustomerUser(); + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + + loginTenantAdmin(); + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isOk()); + AlarmComment expectedAlarmComment = AlarmComment.builder() + .alarmId(alarm.getId()) + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", TENANT_ADMIN_EMAIL)) + .build(); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); + } + @Test public void testDeleteAlarmViaTenant() throws Exception { loginTenantAdmin(); @@ -237,7 +282,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); - assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s", TENANT_ADMIN_EMAIL)); AlarmComment expectedAlarmComment = AlarmComment.builder() diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index ee2aefc81a..d1621b29ec 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest { Assert.assertEquals(1, usageInfo.getCustomers()); Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers()); - Assert.assertEquals(2, usageInfo.getUsers()); + Assert.assertEquals(3, usageInfo.getUsers()); Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers()); Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards()); @@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest { } usageInfo = doGet("/api/usage", UsageInfo.class); - Assert.assertEquals(users.size() + 2, usageInfo.getUsers()); + Assert.assertEquals(users.size() + 3, usageInfo.getUsers()); List dashboards = new ArrayList<>(); for (int i = 0; i < 97; i++) { diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index 957b3721e0..908981b0ca 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -717,6 +717,7 @@ public class UserControllerTest extends AbstractControllerTest { String email = "testEmail1"; List expectedCustomerUserIds = new ArrayList<>(); expectedCustomerUserIds.add(customerUserId); + expectedCustomerUserIds.add(secondCustomerUserId); for (int i = 0; i < 45; i++) { User customerUser = createCustomerUser(customerId); customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org"); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java index fb9807a2a8..c2f7c39ce5 100644 --- a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -15,20 +15,29 @@ */ package org.thingsboard.server.service.ai; -import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.ResourceLock; -import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest { private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); - @BeforeEach - void enableSsrfProtection() { - SsrfProtectionValidator.setEnabled(true); - } - @AfterEach - void disableSsrfProtection() { + void resetSsrfProtection() { SsrfProtectionValidator.setEnabled(false); } + // ============================== Configuration correctness (one per provider) ============================== + // For each provider we feed a fully populated config and assert that the returned ChatModel carries the same + // values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection. + + @Test + void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() { + // GIVEN + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("https://api.openai.com/v1") + .apiKey("test-key") + .build()) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() { + // GIVEN + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() { + // GIVEN + var config = GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-key")) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() { + // GIVEN + var config = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleVertexAiGeminiProviderConfig( + "key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY)) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() { + // GIVEN + var config = MistralAiChatModelConfig.builder() + .providerConfig(new MistralAiProviderConfig("test-key")) + .modelId("mistral-large-latest") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("mistral-large-latest"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() { + // GIVEN + var config = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-key")) + .modelId("claude-opus-4-8") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("claude-opus-4-8"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + @Test - void configureChatModel_openAi_withPrivateIp_shouldThrow() { + void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() { + // GIVEN + var config = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig( + "us-east-1", "test-access-key-id", "test-secret-access-key")) + .modelId("anthropic.claude-3-5-sonnet-20240620-v1:0") + .temperature(0.7) + .topP(0.9) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() { + // GIVEN + var config = GitHubModelsChatModelConfig.builder() + .providerConfig(new GitHubModelsProviderConfig("ghp-test-token")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens + } + + @Test + void shouldConfigureOllamaModel_whenGivenOllamaConfig() { + // GIVEN + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .temperature(0.7) + .topP(0.9) + .topK(40) + .contextLength(4096) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("llama3"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens + } + + // ============================== Base URL SSRF validation ============================== + // Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses + // when SSRF protection is enabled. + + @Test + void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://172.17.0.1:8080/") @@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + void shouldThrow_whenOpenAiBaseUrlIsLocalhost() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://localhost:22/") @@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = AzureOpenAiChatModelConfig.builder() .providerConfig(new AzureOpenAiProviderConfig( "http://10.0.0.1:8080/", null, "test-key")) .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_ollama_withPrivateIp_shouldThrow() { + void shouldThrow_whenOllamaBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OllamaChatModelConfig.builder() .providerConfig(new OllamaProviderConfig( "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) .modelId("llama3") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } - @Test - void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { - // GIVEN - var providerConfig = new GoogleVertexAiGeminiProviderConfig( - "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY - ); - var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() - .providerConfig(providerConfig) - .modelId("gemini-2.0-flash") - .frequencyPenalty(0.3) - .presencePenalty(0.7) - .build(); - - // WHEN - ChatModel chatModel = configurer.configureChatModel(chatModelConfig); - - // THEN - var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); - assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); - assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); - } - } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index 86b5ae2cf8..53c855aa4d 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.state.DeviceStateService; @@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest { private TbCoreConsumerStats statsMock; @Mock private RuleEngineCallService ruleEngineCallServiceMock; + @Mock + private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock; @Mock private TbCallback tbCallbackMock; @@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest { then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); } + @Test + public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java new file mode 100644 index 0000000000..1cf41eca0c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doCallRealMethod; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbRuleEngineConsumerServiceTest { + + @Mock + private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock; + @Mock + private TbCallback tbCallbackMock; + + @Mock + private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock; + + @Test + public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build(); + var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg); + doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock); + + // WHEN + defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + +} diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 3b7248ee72..8ffa489f7b 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsSessionsExecutor.shutdownNow(); } log.info("Stopping CoAP server!"); - server.destroy(); + if (server != null) { + server.destroy(); + } log.info("CoAP server stopped!"); } @@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial private CoapServer createCoapServer() throws UnknownHostException { Configuration networkConfig = createNetworkConfiguration(); - server = new CoapServer(networkConfig); - - CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); - InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); - InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); - noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + try { + server = new CoapServer(networkConfig); + CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); + InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); + noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + + noSecCoapEndpointBuilder.setConfiguration(networkConfig); + CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); + server.addEndpoint(noSecCoapEndpoint); + if (isDtlsEnabled()) { + createDtlsEndpoint(networkConfig); + dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); + dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + } + Resource root = server.getRoot(); + TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); + server.setMessageDeliverer(messageDeliverer); - noSecCoapEndpointBuilder.setConfiguration(networkConfig); - CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); - server.addEndpoint(noSecCoapEndpoint); - if (isDtlsEnabled()) { - createDtlsEndpoint(networkConfig); - dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); - dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + server.start(); + return server; + } catch (RuntimeException | UnknownHostException e) { + log.error("Failed to start CoAP server, releasing resources", e); + try { + if (dtlsSessionsExecutor != null) { + dtlsSessionsExecutor.shutdownNow(); + } + if (server != null) { + server.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + server = null; + dtlsSessionsExecutor = null; + dtlsConnector = null; + dtlsCoapEndpoint = null; + tbDtlsCertificateVerifier = null; + } + throw e; } - Resource root = server.getRoot(); - TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); - server.setMessageDeliverer(messageDeliverer); - - server.start(); - return server; } private boolean isDtlsEnabled() { diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java new file mode 100644 index 0000000000..0c9e9fc29d --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java @@ -0,0 +1,145 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.Resource; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.ThingsBoardExecutors; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ScheduledExecutorService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCoapServerServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private CoapServerContext mockCoapServerContext; + + private DefaultCoapServerService service; + private DatagramSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new DefaultCoapServerService(); + ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn(HOST); + when(mockCoapServerContext.getPort()).thenReturn(occupiedPort); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + } + + @AfterEach + public void tearDown() { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + + @Test + public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception { + // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start(). + // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not. + TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1)); + TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class); + Resource mockRoot = mock(Resource.class); + + try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class); + MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> { + when(server.getRoot()).thenReturn(mockRoot); + doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start(); + }); + MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> { + when(builder.setInetSocketAddress(any())).thenReturn(builder); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + when(builder.build()).thenReturn(mock(CoapEndpoint.class)); + })) { + + executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + // DTLS branch was actually entered and the executor was created... + verify(mockDtlsSettings).dtlsConnectorConfig(any()); + // ...and the cleanup branch shut it down and destroyed the server. + verify(mockExecutor).shutdownNow(); + verify(serverMock.constructed().get(0)).destroy(); + } + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 3cb6e024fc..54ce580e66 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -26,6 +26,7 @@ import java.util.Base64; import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.repeat; @@ -39,6 +40,12 @@ public class StringUtils { public static final int INDEX_NOT_FOUND = -1; + public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + + public static boolean containsControlChars(String source) { + return source != null && CONTROL_CHARS.matcher(source).find(); + } + public static boolean isEmpty(String source) { return source == null || source.isEmpty(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 5f7772ed8b..abd25769d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -42,6 +42,8 @@ public sealed interface AiChatModelConfig> extend C withMaxRetries(Integer maxRetries); - boolean supportsJsonMode(); + boolean supportsSchemalessJsonOutput(); + + boolean supportsJsonSchemaOutput(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 490ae67eb2..738e27a3d1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -52,8 +52,13 @@ public record AmazonBedrockChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 77352b06c3..49cebe3e25 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -53,8 +53,13 @@ public record AnthropicChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index d4df82d237..11fc5bcf3e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -54,7 +54,12 @@ public record AzureOpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 264683f7b4..578dfa6aa3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -54,8 +54,13 @@ public record GitHubModelsChatModelConfig( } @Override - public boolean supportsJsonMode() { - return false; + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { + return true; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 82ba3a0295..472e1629dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -55,7 +55,12 @@ public record GoogleAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 4d20a240e6..80502ee2f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -55,7 +55,12 @@ public record GoogleVertexAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 60c208c181..492ca6967f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -54,7 +54,12 @@ public record MistralAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 5f7776a0a6..758487a956 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -54,7 +54,12 @@ public record OllamaChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 3845aaa543..ab7835c7f2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -54,7 +54,12 @@ public record OpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java index 80d08a594d..44ee9744de 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java @@ -24,7 +24,7 @@ public enum AlarmCommentSubType { ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"), UNASSIGNED_BY_USER("Alarm was unassigned by user %s"), UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"), - COMMENT_DELETED("User %s deleted his comment"), + COMMENT_DELETED("Comment was deleted by user %s"), SEVERITY_CHANGED("Alarm severity was updated from %s to %s"); @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java index 03cd1d69cb..c5575b53be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java @@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc; */ public enum RpcError { NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL; + + private static final RpcError[] VALUES = values(); + + /** + * Resolves an {@link RpcError} from the proto {@code error} ordinal. + * Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals + * that a newer node in a mixed-version cluster might emit, so callers never hit an + * {@link ArrayIndexOutOfBoundsException}. + */ + public static RpcError fromProtoErrorCode(int errorCode) { + return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null; + } } diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 784bd97c79..8627de539e 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -585,10 +585,11 @@ public class ProtoUtils { } private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { + TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse(); FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( - new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), - proto.getRpcResponse().getResponse(), - proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); + new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()), + rpcResponse.hasResponse() ? rpcResponse.getResponse() : null, + RpcError.fromProtoErrorCode(rpcResponse.getError())); return new FromDeviceRpcResponseActorMsg( proto.getRequestId(), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 8cbc2ec7ac..aa00b1c1a8 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1274,7 +1274,7 @@ message LocalSubscriptionServiceMsgProto { message FromDeviceRPCResponseProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - string response = 3; + optional string response = 3; int32 error = 4; } diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index 78e30aa480..cf376365b5 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -228,6 +228,17 @@ class ProtoUtilsTest { assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); } + @Test + void protoFromDeviceRpcResponseOnewaySerialization() { + // Oneway RPC success: response and error are both null. Relies on the proto + // 'optional string response' presence bit so the receiver round-trips null + // rather than seeing the proto3 default "". + FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null)); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + @Test void protoRemoveRpcActorSerialization() { RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 9b370d0b71..639e0bf74a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -82,13 +82,32 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); - this.server = getLhBootstrapServer(); - this.server.start(); - log.info("Started LwM2M transport bootstrap server."); + LeshanBootstrapServer bootstrapServer = null; + try { + bootstrapServer = getLhBootstrapServer(); + this.server = bootstrapServer; + bootstrapServer.start(); + log.info("Started LwM2M transport bootstrap server."); + } catch (RuntimeException e) { + log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e); + try { + if (bootstrapServer != null) { + bootstrapServer.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + this.server = null; + } + throw e; + } } @PreDestroy public void shutdown() { + if (server == null) { + return; + } try { log.info("Stopping LwM2M transport bootstrap server!"); server.destroy(); diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java new file mode 100644 index 0000000000..67e8265361 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java @@ -0,0 +1,115 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.bootstrap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2MTransportBootstrapServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private LwM2MTransportServerConfig serverConfig; + + @Mock + private LwM2MTransportBootstrapConfig bootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; + + @Mock + private TransportService transportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; + + private LwM2MTransportBootstrapService service; + private DatagramSocket occupiedPlain; + private DatagramSocket occupiedSecure; + + @BeforeEach + public void setUp() throws Exception { + occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + + when(bootstrapConfig.getHost()).thenReturn(HOST); + when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort()); + when(bootstrapConfig.getSecureHost()).thenReturn(HOST); + when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort()); + when(bootstrapConfig.getSslCredentials()).thenReturn(null); + + when(serverConfig.isRecommendedCiphers()).thenReturn(false); + when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false); + when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + when(serverConfig.getDtlsCidLength()).thenReturn(null); + + service = new LwM2MTransportBootstrapService( + serverConfig, + bootstrapConfig, + lwM2MBootstrapSecurityStore, + lwM2MInMemoryBootstrapConfigStore, + transportService, + certificateVerifier + ); + } + + @AfterEach + public void tearDown() { + if (occupiedPlain != null && !occupiedPlain.isClosed()) { + occupiedPlain.close(); + } + if (occupiedSecure != null && !occupiedSecure.isClosed()) { + occupiedSecure.close(); + } + } + + @Test + public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index e6c3cab1ba..e7ef634c72 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { - log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS); + TenantProfile profile = update.getProfile(); + log.info("Received tenant profile update: {}", profile); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS); + EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); } @@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); + TenantProfile profile = tenantProfileCache.get(tenantId); + update(tenantId, + createRateLimits(profile, TENANT_LIMITS), + createRateLimits(profile, DEVICE_LIMITS), + createRateLimits(profile, GATEWAY_LIMITS), + createRateLimits(profile, GATEWAY_DEVICE_LIMITS) + ); } private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype, @@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi BiConsumer putFunction) { EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId); if (oldRateLimits == null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] New rate limits: {}", entityId, newRateLimits); - } else { - log.debug("[{}] New rate limits: {}", entityId, newRateLimits); - } + logLimits(entityId, "New", newRateLimits); putFunction.accept(entityId, newRateLimits); } else { EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits); if (updated != null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] Updated rate limits: {}", entityId, updated); - } else { - log.debug("[{}] Updated rate limits: {}", entityId, updated); - } + logLimits(entityId, "Updated", updated); putFunction.accept(entityId, updated); } } } + private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) { + // Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG. + if (EntityType.TENANT.equals(entityId.getEntityType())) { + log.info("[{}] {} rate limits: {}", entityId, action, limits); + } else { + log.debug("[{}] {} rate limits: {}", entityId, action, limits); + } + } + private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) { boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration()); boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration()); @@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); - } else { - TransportRateLimit regularMsgRateLimit; - TransportRateLimit telemetryMsgRateLimit; - TransportRateLimit telemetryDpRateLimit; - switch (limitsType) { - case TENANT_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); - } - case DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); - } - case GATEWAY_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); - } - case GATEWAY_DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit()); - } - default -> throw new IllegalStateException("Unknown limits type: " + limitsType); - } - - return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } + return new EntityTransportRateLimits( + newLimit(limitsType.getRegularMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile)) + ); } private static TransportRateLimit newLimit(String config) { @@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); + return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { - return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - getTenantDevices(tenantId).add(deviceId); - return limits; - }); + return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId)); } private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - getTenantGateways(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId)); } private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - getTenantGatewayDevices(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId)); + } + + private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId, + T entityId, TransportLimitsType limitsType, Runnable onMiss) { + EntityTransportRateLimits limits = limitsMap.get(entityId); + if (limits == null) { + // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may + // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function. + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType)); + // Runs on every observed miss, including callers that lost the computeIfAbsent race and got an + // existing value back - NOT only on actual creation, so the callback must be idempotent. + if (onMiss != null) { + onMiss.run(); + } + } + return limits; } private Set getTenantDevices(TenantId tenantId) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java index 29077877a8..3a124da2b6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -15,6 +15,39 @@ */ package org.thingsboard.server.common.transport.limits; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.function.Function; + +@Getter +@RequiredArgsConstructor public enum TransportLimitsType { - TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS + + TENANT_LIMITS( + DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit + ), + DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit + ), + GATEWAY_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit + ), + GATEWAY_DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit + ); + + private final Function regularMsgRateLimit; + private final Function telemetryMsgRateLimit; + private final Function telemetryDataPointsRateLimit; + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 361453abff..65b24ef33a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements private int notificationsPollDuration; @Value("${transport.stats.enabled:false}") private boolean statsEnabled; + @Value("${transport.callback_thread_pool_size:20}") + private int callbackThreadPoolSize; @Autowired @Lazy @@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer"); this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer"); this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer"); - this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); + this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass()); this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS); transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate(); transportApiRequestTemplate.setMessagesStats(transportApiStats); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java index 4923bcd9a2..ac2fd4c28a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.service; +import com.google.common.util.concurrent.Striped; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -37,14 +38,20 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; @Component @TbTransportComponent @Slf4j public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { - private final Lock tenantProfileFetchLock = new ReentrantLock(); + // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache + // misses (cached tenants never take the lock), and concurrent fetches are already bounded by the + // transport callback pool, so this comfortably over-provisions the realistic concurrency. + private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024; + + // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while + // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size). + private final Striped tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES); private final ConcurrentMap profiles = new ConcurrentHashMap<>(); private final ConcurrentMap tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>(); @@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil } private TenantProfile getTenantProfile(TenantId tenantId) { - TenantProfile profile = null; - TenantProfileId tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + TenantProfile profile = lookupCached(tenantId); if (profile == null) { - tenantProfileFetchLock.lock(); + // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing + // different tenants to resolve their profiles concurrently. + Lock lock = tenantProfileFetchLocks.get(tenantId); + lock.lock(); try { - tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + profile = lookupCached(tenantId); if (profile == null) { - TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() - .setEntityType(EntityType.TENANT.name()) - .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) - .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) - .build(); - TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); - profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); - TenantProfile existingProfile = profiles.get(profile.getId()); - if (existingProfile != null) { - profile = existingProfile; - } else { - profiles.put(profile.getId(), profile); - } - tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); - tenantIds.put(tenantId, profile.getId()); - ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); - rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + profile = fetchAndCacheTenantProfile(tenantId); } } finally { - tenantProfileFetchLock.unlock(); + lock.unlock(); } } return profile; } + private TenantProfile lookupCached(TenantId tenantId) { + TenantProfileId tenantProfileId = tenantIds.get(tenantId); + if (tenantProfileId != null) { + return profiles.get(tenantProfileId); + } + return null; + } + + private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) { + TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) + .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) + .build(); + TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); + TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); + TenantProfile existingProfile = profiles.get(profile.getId()); + if (existingProfile != null) { + profile = existingProfile; + } else { + profiles.put(profile.getId(), profile); + } + tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); + tenantIds.put(tenantId, profile.getId()); + ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); + rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + return profile; + } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java new file mode 100644 index 0000000000..b7b6a81abf --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java @@ -0,0 +1,202 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.limits; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.transport.TransportTenantProfileCache; +import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTransportRateLimitServiceTest { + + private TransportTenantProfileCache tenantProfileCache; + private ExecutorService executor; + + private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID()); + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TransportTenantProfileCache.class); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception { + // Two concurrent rate-limit checks for the SAME tenant must both be able to reach + // the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside + // ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation + // node and never reaches the fetch -> the latch never reaches zero. + CountDownLatch bothCallersReachedFetch = new CountDownLatch(2); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> { + bothCallersReachedFetch.countDown(); + releaseFetch.await(5, TimeUnit.SECONDS); + return tenantProfile(); + }); + + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + Runnable check = () -> service.checkLimits(tenant, null, null, 1, false); + executor.submit(check); + executor.submit(check); + + boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS); + releaseFetch.countDown(); + + assertThat(bothReached) + .as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)") + .isTrue(); + } + + @ParameterizedTest + @EnumSource(TransportLimitsType.class) + void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) { + // Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS + // wired to the plain gateway getters) resolves to the wrong value and fails the assertion. + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setTransportTenantMsgRateLimit("tenant-msg"); + config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg"); + config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp"); + config.setTransportDeviceMsgRateLimit("device-msg"); + config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg"); + config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp"); + config.setTransportGatewayMsgRateLimit("gateway-msg"); + config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg"); + config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp"); + config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg"); + config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg"); + config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp"); + + String prefix = switch (type) { + case TENANT_LIMITS -> "tenant"; + case DEVICE_LIMITS -> "device"; + case GATEWAY_LIMITS -> "gateway"; + case GATEWAY_DEVICE_LIMITS -> "gateway-device"; + }; + + assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg"); + assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg"); + assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp"); + } + + @ParameterizedTest + @EnumSource(EntityLevel.class) + void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) { + DeviceId entity = new DeviceId(UUID.randomUUID()); + when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600")); + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + // First check resolves the (permissive) limit and must register the entity into the per-tenant + // tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it. + assertThat(level.check(service, tenant, entity)) + .as("permissive limit should allow the first %s check", level).isNull(); + + // Tighten the limit to a single message and push a profile update for this tenant. + service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant))); + + // The freshly merged "1:600" bucket allows exactly one message... + assertThat(level.check(service, tenant, entity)).isNull(); + // ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity. + assertThat(level.check(service, tenant, entity)) + .as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull(); + } + + private TenantProfile tenantProfile() { + return profileWith(new DefaultTenantProfileConfiguration()); + } + + private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) { + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + level.setRegularMsgRateLimit(config, regularMsgRateLimit); + return profileWith(config); + } + + private TenantProfile profileWith(DefaultTenantProfileConfiguration config) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("test-profile"); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + return profile; + } + + private enum EntityLevel { + DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, false); + } + }, + GATEWAY { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, entityId, null, 0, false); + } + }, + GATEWAY_DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, true); + } + }; + + abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value); + + abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java new file mode 100644 index 0000000000..d2b4544f53 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java @@ -0,0 +1,191 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import com.google.common.util.concurrent.Striped; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DefaultTransportTenantProfileCacheTest { + + private DefaultTransportTenantProfileCache cache; + private TransportService transportService; + private TransportRateLimitService rateLimitService; + private ExecutorService executor; + + // Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES. + private static final int STRIPE_COUNT = 1024; + + private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID()); + // Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant + // test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe. + private final TenantId tenantB = differentStripeFrom(tenantA); + + private static TenantId differentStripeFrom(TenantId other) { + Striped probe = Striped.lock(STRIPE_COUNT); + TenantId candidate = TenantId.fromUUID(UUID.randomUUID()); + while (probe.get(candidate) == probe.get(other)) { + candidate = TenantId.fromUUID(UUID.randomUUID()); + } + return candidate; + } + + @BeforeEach + void setUp() { + cache = new DefaultTransportTenantProfileCache(); + transportService = mock(TransportService.class); + rateLimitService = mock(TransportRateLimitService.class); + doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean()); + cache.setTransportService(transportService); + cache.setRateLimitService(rateLimitService); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception { + CountDownLatch tenantAFetchStarted = new CountDownLatch(1); + CountDownLatch releaseTenantA = new CountDownLatch(1); + + GetEntityProfileResponseMsg responseA = responseFor(tenantA); + GetEntityProfileResponseMsg responseB = responseFor(tenantB); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + GetEntityProfileRequestMsg msg = invocation.getArgument(0); + TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + if (requested.equals(tenantA)) { + tenantAFetchStarted.countDown(); + releaseTenantA.await(5, TimeUnit.SECONDS); + return responseA; + } + return responseB; + }); + + // T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip. + Future tenantAResult = executor.submit(() -> cache.get(tenantA)); + assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS)) + .as("tenantA fetch should have started").isTrue(); + + // T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch. + // Fails today (single global lock); passes once locking is per-tenant. + TenantProfile tenantBProfile = CompletableFuture + .supplyAsync(() -> cache.get(tenantB), executor) + .get(2, TimeUnit.SECONDS); + assertThat(tenantBProfile).isNotNull(); + + releaseTenantA.countDown(); + assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull(); + } + + @Test + void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception { + // The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse + // into a single cross-service fetch (the rest are served from cache). Assert that contract directly. + int callers = 8; + CountDownLatch fetchStarted = new CountDownLatch(1); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + fetchStarted.countDown(); + // Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock. + releaseFetch.await(5, TimeUnit.SECONDS); + return responseFor(tenantA); + }); + + CountDownLatch allSubmitted = new CountDownLatch(callers); + List> results = new ArrayList<>(); + for (int i = 0; i < callers; i++) { + results.add(executor.submit(() -> { + allSubmitted.countDown(); + return cache.get(tenantA); + })); + } + + assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue(); + assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue(); + releaseFetch.countDown(); + + for (Future result : results) { + assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull(); + } + // All 8 callers resolved the same tenant, but only one of them hit the backend. + verify(transportService, times(1)).getEntityProfile(any()); + } + + private GetEntityProfileResponseMsg responseFor(TenantId tenantId) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("profile-" + tenantId.getId()); + return GetEntityProfileResponseMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setTenantProfile(ProtoUtils.toProto(profile)) + .setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId))) + .build(); + } + + private ApiUsageState enabledApiUsageState(TenantId tenantId) { + ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + state.setTenantId(tenantId); + state.setEntityId(tenantId); + state.setTransportState(ApiUsageStateValue.ENABLED); + state.setDbStorageState(ApiUsageStateValue.ENABLED); + state.setReExecState(ApiUsageStateValue.ENABLED); + state.setJsExecState(ApiUsageStateValue.ENABLED); + state.setTbelExecState(ApiUsageStateValue.ENABLED); + state.setEmailExecState(ApiUsageStateValue.ENABLED); + state.setSmsExecState(ApiUsageStateValue.ENABLED); + state.setAlarmExecState(ApiUsageStateValue.ENABLED); + state.setVersion(1L); + return state; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 7035fcfd9b..c053f36d5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -18,10 +18,13 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; @@ -69,9 +72,24 @@ public class DeviceCredentialsDataValidator extends DataValidator validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("credentialsId") + .hasMessageContaining("control characters"); + } + + @Test + void rejectsCarriageReturnInAccessToken() { + DeviceCredentials creds = accessToken("token\rprivileged: true"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("control characters"); + } + + @Test + void rejectsNewlineInMqttClientId() { + DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("clientId"); + } + + @Test + void rejectsNewlineInMqttUserName() { + DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("userName"); + } + + @Test + void rejectsNewlineInMqttPassword() { + DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("password"); + } + + @Test + void acceptsValidCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = accessToken("safe_token_123"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + @Test + void acceptsValidMqttBasicCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = mqttBasic("client-1", "user-1", "pwd-1"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + private DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java index d4c93a1d5f..2c6fee2fcf 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java @@ -16,6 +16,13 @@ package org.thingsboard.server.dao.util; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +36,120 @@ class DeviceConnectivityUtilTest { assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); } + @Test + void validAccessTokenIsRenderedAsIs() throws Exception { + String yaml = renderCompose(accessToken("safe_token_123")); + + assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n"); + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInAccessTokenIsSanitized() throws Exception { + String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]"; + + String yaml = renderCompose(accessToken(malicious)); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void carriageReturnInAccessTokenIsSanitized() throws Exception { + String yaml = renderCompose(accessToken("token\rprivileged: true")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttClientIdIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttUserNameIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttPasswordIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("cid", "u\";touch pwned;echo \"", "pwd")); + + // the double quote must be backslash-escaped so it cannot terminate the -u "..." argument + assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\""); + assertThat(command).doesNotContain("-u \"u\";"); + } + + @Test + void controlCharsInMqttClientIdAreStrippedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("c\nid", "user", "pwd")); + + assertThat(command).doesNotContain("\n"); + assertThat(command).contains("-i \"c_id\""); + } + + @Test + void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() { + DeviceCredentials creds = accessToken("tok\nen"); + + assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + } + + private static String renderCompose(DeviceCredentials credentials) throws Exception { + var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( + "host.docker.internal", "3.8-stable", credentials); + try (var in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private static DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + + private static void assertNoInjectedSiblingKeys(String yaml) throws IOException { + for (String line : yaml.split("\n")) { + String trimmed = line.replaceFirst("^\\s+", ""); + assertThat(trimmed) + .as("unexpected sibling key — possible YAML injection: %s", line) + .doesNotStartWith("entrypoint:") + .doesNotStartWith("privileged:") + .doesNotStartWith("command:"); + } + } + } diff --git a/pom.xml b/pom.xml index 23cc410a0f..70dbf9f43e 100755 --- a/pom.xml +++ b/pom.xml @@ -144,14 +144,12 @@ 1.7.5 3.8.0 1.18.4 - 1.8.0-TB - 2.5.9 - 2.38.0 + 1.16.1-TB1 + 2.43.0 1.24 1.11.0 - 3.49.3 + 3.42.0 0.27.0 - 1.7.0 2.7.3 1.5.6 @@ -1395,11 +1393,6 @@ postgresql ${postgresql.version} - - org.apache.opennlp - opennlp-tools - ${opennlp-tools.version} - commons-io commons-io @@ -2044,11 +2037,6 @@ perfmark-api ${perfmark-api.version} - - org.threeten - threetenbp - ${threetenbp.version} - diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 7495276723..d6a5f26b4e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -52,10 +52,9 @@ import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; -import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.resource.TbResourceDataCache; +import org.thingsboard.server.exception.DataValidationException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -69,7 +68,6 @@ import java.util.Set; import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @Slf4j @@ -92,7 +90,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/" + docUrl = "https://thingsboard.io/docs/reference/rule-engine/nodes/external/ai-request/" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @@ -126,13 +124,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); } AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); - if (isJsonModeConfigured(config)) { - if (!chatModelConfig.supportsJsonMode()) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true); - } - // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT - responseFormat = config.getResponseFormat().toLangChainResponseFormat(); + TbResponseFormat tbResponseFormat = config.getResponseFormat(); + if (!tbResponseFormat.isSupportedBy(chatModelConfig)) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + tbResponseFormat.type() + "' response format", true); } + responseFormat = tbResponseFormat.toLangChainResponseFormat(); if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { resourceIds = new HashSet<>(config.getResourceIds().size()); for (UUID resourceId : config.getResourceIds()) { @@ -148,11 +144,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so } - private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) { - var responseFormatType = config.getResponseFormat().type(); - return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA; - } - @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index a32469be42..5dd1cd74f0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.validation.ValidJsonSchema; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @@ -41,6 +42,8 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes TbResponseFormatType type(); + boolean isSupportedBy(AiChatModelConfig modelConfig); + ResponseFormat toLangChainResponseFormat(); enum TbResponseFormatType { @@ -58,6 +61,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.TEXT; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return true; + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.TEXT; @@ -72,6 +80,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsSchemalessJsonOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.JSON; @@ -86,6 +99,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON_SCHEMA; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsJsonSchemaOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.builder() diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index e8aad50463..3a184267a5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -38,8 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.DirectListeningExecutor; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; @@ -53,8 +53,10 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; @@ -400,6 +402,124 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenJsonSchemaResponseFormatAndModelSupportsIt_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Anthropic does not support schemaless JSON mode, but does support JSON Schema constrained output + modelConfig = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenJsonSchemaResponseFormatAndBedrockModel_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Bedrock does not support schemaless JSON mode, but does support JSON Schema constrained output (Converse API) + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenSchemalessJsonResponseFormatAndBedrockModel_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(new TbJsonResponseFormat()); + + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + @Test void givenNotExistingResources_whenInit_thenThrowsException() { // GIVEN @@ -669,7 +789,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); return true; @@ -706,7 +826,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(config.getUserPrompt()))); return true; }) @@ -993,7 +1113,7 @@ class TbAiNodeTest { then(aiChatModelServiceMock).should().sendChatRequestAsync( any(), argThat(actualChatRequest -> { - assertThat(actualChatRequest.responseFormat()).isNull(); + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.TEXT).build()); return true; }) ); diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index c85662b478..623ecebae3 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -138,6 +138,8 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" # Local CoAP transport parameters coap: # CoaP processing timeout in milliseconds diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 261edc70cf..2b1d3dff8e 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -173,6 +173,8 @@ redis: # HTTP server parameters # Configures HTTP transport request timeouts, payload limits, session management, JSON processing, logging, and statistics. transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" http: # HTTP request processing timeout in milliseconds request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 5f5fed8bae..67046ee798 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -140,6 +140,8 @@ redis: # LWM2M server parameters # Session, JSON processing, RPC, logging, and LwM2M-specific transport settings including DTLS, bootstrap, and OTA. transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. # The parameter value is in milliseconds. diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index a193b1e4b6..b5a0e4997f 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -141,6 +141,8 @@ redis: # MQTT server parameters # Configures the MQTT transport layer including bind address/port, SSL, Netty tuning, session management, rate limits, and message processing options. transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" mqtt: # MQTT bind-address bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 75d6b3f844..b0c4155924 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -140,6 +140,8 @@ redis: # Snmp server parameters # Configures the SNMP transport protocol, bind port, PDU limits, session management, JSON handling, logging, and statistics. transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" snmp: # Enable/disable SNMP transport protocol enabled: "${SNMP_ENABLED:true}" diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 9b0a51d7ec..fd866fe52a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -102,9 +102,15 @@ {{ 'rule-node-config.ai.response-format' | translate }} - {{ 'rule-node-config.ai.response-text' | translate }} - {{ 'rule-node-config.ai.response-json' | translate }} - {{ 'rule-node-config.ai.response-json-schema' | translate }} + @if (allowedResponseFormats.includes(responseFormat.TEXT)) { + {{ 'rule-node-config.ai.response-text' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON)) { + {{ 'rule-node-config.ai.response-json' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON_SCHEMA)) { + {{ 'rule-node-config.ai.response-json-schema' | translate }} + } { + switch (provider) { + case AiProvider.ANTHROPIC: + case AiProvider.AMAZON_BEDROCK: + return [ResponseFormat.TEXT, ResponseFormat.JSON_SCHEMA]; + default: + return [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA]; + } +}; + export interface AiModelWithUserMsg { userMessage: { contents: Array<{contentType: string; text: string}>; diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index fc39de2abc..538affbc5a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Sprog" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 2dfdd9645d..48d2698501 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Sprache" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index ebd4d4df2d..6a7c63e466 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Γλώσσα" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b1a3996e3c..764676dfbc 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -667,7 +667,7 @@ "assigned-to-user": "Alarm was assigned by user {{userName}} to user {{assigneeName}}", "unassigned-to-user": "Alarm was unassigned by user {{userName}}", "unassigned-from-deleted-user": "Alarm was unassigned because user {{userName}} - was deleted", - "comment-deleted": "User {{userName}} deleted his comment", + "comment-deleted": "Comment was deleted by user {{userName}}", "severity-changed": "Alarm severity was updated from {{oldSeverity}} to {{newSeverity}}" } }, @@ -1580,8 +1580,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index c919107272..61ab94ae77 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 15fa9b5586..1b6070833f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Langue" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index 00de105518..08ad1c41b2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Lingua" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json index 84345c6216..2d5954e59a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "自動", "language": "言語" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json index 893453031b..50d766ea91 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -1106,8 +1106,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json index 7926c16961..4acdcdafae 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Taal" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-no_NO.json b/ui-ngx/src/assets/locale/locale.constant-no_NO.json index be53c9624d..85bb5f57d5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-no_NO.json +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Språk" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index a602a04529..8a59259efb 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Automático", "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index 0027c7d8ed..f49fe1d95e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Dil" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index 5e46c556ad..551397bbee 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Мова" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 8ed93ba0a4..abfb4cb788 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "自动", "language": "语言" } -} \ No newline at end of file +}