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
+}