Browse Source

Merge pull request #13371 from dskarzh/ai

AI rule node
pull/13734/head
Viacheslav Klimov 1 year ago
committed by GitHub
parent
commit
7af5ec0bee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      application/pom.xml
  2. 10
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 12
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  4. 176
      application/src/main/java/org/thingsboard/server/controller/AiModelController.java
  5. 19
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  6. 3
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  7. 20
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java
  8. 40
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  9. 27
      application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java
  10. 86
      application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java
  11. 269
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  12. 4
      application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
  13. 8
      application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java
  14. 4
      application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java
  15. 80
      application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java
  16. 27
      application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java
  17. 23
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  18. 5
      application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
  19. 17
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  20. 3
      application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java
  21. 36
      application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java
  22. 77
      application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java
  23. 7
      application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java
  24. 4
      application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java
  25. 44
      application/src/main/resources/thingsboard.yml
  26. 615
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  27. 268
      application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
  28. 42
      common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java
  29. 4
      common/data/pom.xml
  30. 6
      common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java
  31. 7
      common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java
  32. 8
      common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java
  33. 102
      common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java
  34. 79
      common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java
  35. 68
      common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java
  36. 73
      common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java
  37. 32
      common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java
  38. 78
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
  39. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java
  40. 47
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
  41. 56
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
  42. 57
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
  43. 58
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
  44. 58
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
  45. 59
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
  46. 59
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
  47. 38
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java
  48. 58
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
  49. 58
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
  50. 29
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java
  51. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java
  52. 24
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java
  53. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java
  54. 24
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java
  55. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java
  56. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java
  57. 26
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java
  58. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java
  59. 22
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
  60. 51
      common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java
  61. 4
      common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
  62. 7
      common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java
  63. 39
      common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java
  64. 39
      common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java
  65. 22
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java
  66. 1
      common/proto/src/main/proto/queue.proto
  67. 5
      common/util/pom.xml
  68. 24
      common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java
  69. 45
      common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java
  70. 23
      dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java
  71. 45
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java
  72. 57
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java
  73. 33
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java
  74. 37
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java
  75. 36
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java
  76. 149
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java
  77. 2
      dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java
  78. 8
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  79. 110
      dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java
  80. 10
      dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java
  81. 31
      dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java
  82. 29
      dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java
  83. 56
      dao/src/main/java/org/thingsboard/server/dao/service/Validator.java
  84. 69
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java
  85. 89
      dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java
  86. 139
      dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java
  87. 3
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  88. 2
      dao/src/main/resources/sql/schema-entities-idx.sql
  89. 12
      dao/src/main/resources/sql/schema-entities.sql
  90. 5
      dao/src/test/resources/application-test.properties
  91. 112
      pom.xml
  92. 4
      rule-engine/rule-engine-api/pom.xml
  93. 27
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java
  94. 5
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  95. 43
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java
  96. 158
      rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java
  97. 4
      rule-engine/rule-engine-components/pom.xml
  98. 136
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java
  99. 203
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
  100. 68
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java

38
application/pom.xml

@ -381,6 +381,44 @@
<groupId>org.rocksdb</groupId>
<artifactId>rocksdbjni</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-azure-open-ai</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-google-ai-gemini</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-vertex-ai-gemini</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mistral-ai</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-anthropic</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bedrock</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-github-models</artifactId>
<exclusions>
<exclusion>
<groupId>com.azure</groupId>
<artifactId>azure-core-test</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>

10
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -35,6 +35,7 @@ import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
@ -62,6 +63,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.common.msg.tools.TbRateLimits;
import org.thingsboard.server.common.stats.TbApiUsageReportClient;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -311,6 +313,14 @@ public class ActorSystemContext {
@Getter
private AuditLogService auditLogService;
@Autowired
@Getter
private RuleEngineAiChatModelService aiChatModelService;
@Autowired
@Getter
private AiModelService aiModelService;
@Autowired
@Getter
private EntityViewService entityViewService;

12
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -28,6 +28,7 @@ import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
@ -76,6 +77,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.TbMsgProcessingStackItem;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -1024,6 +1026,16 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getAuditLogService();
}
@Override
public RuleEngineAiChatModelService getAiChatModelService() {
return mainCtx.getAiChatModelService();
}
@Override
public AiModelService getAiModelService() {
return mainCtx.getAiModelService();
}
@Override
public MqttClientSettings getMqttClientSettings() {
return mainCtx.getMqttClientSettings();

176
application/src/main/java/org/thingsboard/server/controller/AiModelController.java

@ -0,0 +1,176 @@
/**
* Copyright © 2016-2025 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.controller;
import com.google.common.util.concurrent.ListenableFuture;
import dev.langchain4j.model.chat.request.ChatRequest;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.dto.TbChatRequest;
import org.thingsboard.server.common.data.ai.dto.TbChatResponse;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.service.ai.AiChatModelService;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/ai/model")
class AiModelController extends BaseController {
private final AiChatModelService aiChatModelService;
@ApiOperation(
value = "Create or update AI model (saveAiModel)",
notes = "Creates or updates an AI model record.\n\n" +
"• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new record and returns it in the `id` field of the response.\n\n" +
"• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" +
"Tenant ID for the AI model will be taken from the authenticated user making the request, regardless of any value provided in the request body." +
TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping
public AiModel saveAiModel(@RequestBody @Valid AiModel model) throws ThingsboardException {
var user = getCurrentUser();
model.setTenantId(user.getTenantId());
checkEntity(model.getId(), model, Resource.AI_MODEL);
return tbAiModelService.save(model, user);
}
@ApiOperation(
value = "Get AI model by ID (getAiModelById)",
notes = "Fetches an AI model record by its `id`." +
TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping("/{modelUuid}")
public AiModel getAiModelById(
@Parameter(
description = "ID of the AI model record",
required = true,
example = "de7900d4-30e2-11f0-9cd2-0242ac120002"
)
@PathVariable UUID modelUuid
) throws ThingsboardException {
return checkAiModelId(new AiModelId(modelUuid), Operation.READ);
}
@ApiOperation(
value = "Get AI models (getAiModels)",
notes = "Returns a page of AI models. " +
PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping
public PageData<AiModel> getAiModels(
@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
@RequestParam int pageSize,
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
@RequestParam int page,
@Parameter(description = AI_MODEL_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch,
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"}))
@RequestParam(required = false) String sortProperty,
@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"}))
@RequestParam(required = false) String sortOrder
) throws ThingsboardException {
var user = getCurrentUser();
accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.READ);
var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return aiModelService.findAiModelsByTenantId(user.getTenantId(), pageLink);
}
@ApiOperation(
value = "Delete AI model by ID (deleteAiModelById)",
notes = "Deletes the AI model record by its `id`. " +
"If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " +
"If no such record exists, the endpoint returns `false`." +
TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@DeleteMapping("/{modelUuid}")
public boolean deleteAiModelById(
@Parameter(
description = "ID of the AI model record",
required = true,
example = "de7900d4-30e2-11f0-9cd2-0242ac120002"
)
@PathVariable UUID modelUuid
) throws ThingsboardException {
var user = getCurrentUser();
var modelId = new AiModelId(modelUuid);
accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE);
Optional<AiModel> toDelete = aiModelService.findAiModelByTenantIdAndId(user.getTenantId(), modelId);
if (toDelete.isEmpty()) {
return false;
}
accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE, modelId, toDelete.get());
return tbAiModelService.delete(toDelete.get(), user);
}
@ApiOperation(
value = "Send request to AI chat model (sendChatRequest)",
notes = "Submits a single prompt - made up of an optional system message and a required user message - to the specified AI chat model " +
"and returns either the generated answer or an error envelope." +
TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping("/chat")
public DeferredResult<TbChatResponse> sendChatRequest(@Valid @RequestBody TbChatRequest tbChatRequest) {
ChatRequest langChainChatRequest = tbChatRequest.toLangChainChatRequest();
AiChatModelConfig<?> chatModelConfig = tbChatRequest.chatModelConfig();
ListenableFuture<TbChatResponse> future = aiChatModelService.sendChatRequestAsync(chatModelConfig, langChainChatRequest)
.transform(chatResponse -> (TbChatResponse) new TbChatResponse.Success(chatResponse.aiMessage().text()), directExecutor())
.catching(Throwable.class, ex -> new TbChatResponse.Failure(ex.getMessage()), directExecutor());
Integer requestTimeoutSeconds = chatModelConfig.timeoutSeconds();
return requestTimeoutSeconds != null ? wrapFuture(future, Duration.ofSeconds(requestTimeoutSeconds).toMillis()) : wrapFuture(future);
}
}

19
application/src/main/java/org/thingsboard/server/controller/BaseController.java

@ -61,6 +61,7 @@ import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantInfo;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmInfo;
@ -75,6 +76,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo;
import org.thingsboard.server.common.data.exception.EntityVersionMismatchException;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.AssetId;
@ -129,6 +131,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetTypeInfo;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -175,6 +178,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.action.EntityActionService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.entitiy.TbLogEntityActionService;
import org.thingsboard.server.service.entitiy.ai.TbAiModelService;
import org.thingsboard.server.service.entitiy.user.TbUserSettingsService;
import org.thingsboard.server.service.ota.OtaPackageStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@ -378,6 +382,12 @@ public abstract class BaseController {
@Autowired
protected CalculatedFieldService calculatedFieldService;
@Autowired
protected AiModelService aiModelService;
@Autowired
protected TbAiModelService tbAiModelService;
@Value("${server.log_controller_error_stack_trace}")
@Getter
private boolean logControllerErrorStackTrace;
@ -390,7 +400,7 @@ public abstract class BaseController {
public void handleControllerException(Exception e, HttpServletResponse response) {
ThingsboardException thingsboardException = handleException(e);
if (thingsboardException.getErrorCode() == ThingsboardErrorCode.GENERAL && thingsboardException.getCause() instanceof Exception
&& StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) {
&& StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) {
e = (Exception) thingsboardException.getCause();
} else {
e = thingsboardException;
@ -438,7 +448,7 @@ public abstract class BaseController {
if (exception instanceof ThingsboardException) {
return (ThingsboardException) exception;
} else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException
|| exception instanceof DataValidationException || cause instanceof IncorrectParameterException) {
|| exception instanceof DataValidationException || cause instanceof IncorrectParameterException) {
return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS);
} else if (exception instanceof MessagingException) {
return new ThingsboardException("Unable to send mail", ThingsboardErrorCode.GENERAL);
@ -634,6 +644,7 @@ public abstract class BaseController {
case MOBILE_APP -> checkMobileAppId(new MobileAppId(entityId.getId()), operation);
case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation);
case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation);
case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation);
default -> (HasId<? extends EntityId>) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation);
};
} catch (Exception e) {
@ -837,6 +848,10 @@ public abstract class BaseController {
return checkEntityId(jobId, jobService::findJobById, operation);
}
AiModel checkAiModelId(AiModelId settingsId, Operation operation) throws ThingsboardException {
return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation);
}
protected <I extends EntityId> I emptyId(EntityType entityType) {
return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
}

3
application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java

@ -31,7 +31,7 @@ public class ControllerConstants {
protected static final String ASSIGNEE_ID = "assigneeId";
protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " +
"The result is wrapped with PageData object that allows you to iterate over result set using pagination. " +
"See the 'Model' tab of the Response Class for more details. ";
"See response schema for more details. ";
protected static final String INLINE_IMAGES = "inlineImages";
protected static final String INLINE_IMAGES_DESCRIPTION = "Inline images as a data URL (Base64)";
@ -90,6 +90,7 @@ public class ControllerConstants {
protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name.";
protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name.";
protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name.";
protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID.";
protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name.";
protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title.";

20
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java

@ -0,0 +1,20 @@
/**
* Copyright © 2016-2025 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.ai;
import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService;
public interface AiChatModelService extends RuleEngineAiChatModelService {}

40
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java

@ -0,0 +1,40 @@
/**
* Copyright © 2016-2025 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.ai;
import com.google.common.util.concurrent.FluentFuture;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import lombok.RequiredArgsConstructor;
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;
@Service
@RequiredArgsConstructor
class AiChatModelServiceImpl implements AiChatModelService {
private final Langchain4jChatModelConfigurer chatModelConfigurer;
private final AiRequestsExecutor aiRequestsExecutor;
@Override
public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) {
ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest);
}
}

27
application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2025 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.ai;
import com.google.common.util.concurrent.FluentFuture;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
public interface AiRequestsExecutor {
FluentFuture<ChatResponse> sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest);
}

86
application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java

@ -0,0 +1,86 @@
/**
* Copyright © 2016-2025 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.ai;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import java.time.Duration;
import java.util.concurrent.Executors;
@Lazy
@Component
@RequiredArgsConstructor
class DefaultAiRequestsExecutor implements AiRequestsExecutor {
private final AiRequestsExecutorProperties properties;
@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool")
private static class AiRequestsExecutorProperties {
@NotBlank(message = "Pool name must be not blank")
private String poolName = "ai-requests";
@Min(value = 1, message = "Pool size must be at least 1")
private int poolSize = 50;
@Min(value = 1, message = "Termination timeout must be at least 1 second")
private int terminationTimeoutSeconds = 60;
}
private ListeningExecutorService executorService;
@PostConstruct
private void init() {
executorService = MoreExecutors.listeningDecorator(
Executors.newFixedThreadPool(properties.getPoolSize(), ThingsBoardThreadFactory.forName(properties.getPoolName()))
);
}
@Override
public FluentFuture<ChatResponse> sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest) {
return FluentFuture.from(executorService.submit(() -> chatModel.chat(chatRequest)));
}
@PreDestroy
private void destroy() {
if (executorService != null) {
MoreExecutors.shutdownAndAwaitTermination(executorService, Duration.ofSeconds(properties.getTerminationTimeoutSeconds()));
executorService = null;
}
}
}

269
application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java

@ -0,0 +1,269 @@
/**
* Copyright © 2016-2025 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.ai;
import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.api.gax.retrying.RetrySettings;
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.mistralai.MistralAiChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.stereotype.Component;
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.Langchain4jChatModelConfigurer;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
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 software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.Duration;
@Component
class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer {
@Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
return OpenAiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion())
.apiKey(providerConfig.apiKey())
.deploymentName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) {
return GoogleAiGeminiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.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();
}
@Override
public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) {
GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig();
// construct service account credentials using service account key JSON
ServiceAccountCredentials serviceAccountCredentials;
try {
serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes()));
} 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.setTotalTimeout(org.threeten.bp.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
.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.frequencyPenalty() != null) {
generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().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
public ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig) {
return MistralAiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig) {
return AnthropicChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.topK(chatModelConfig.topK())
.maxTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig) {
AmazonBedrockProviderConfig providerConfig = chatModelConfig.providerConfig();
var credentialsProvider = StaticCredentialsProvider.create(
AwsBasicCredentials.create(providerConfig.accessKeyId(), providerConfig.secretAccessKey())
);
var bedrockClient = BedrockRuntimeClient.builder()
.region(Region.of(providerConfig.region()))
.credentialsProvider(credentialsProvider)
.build();
var defaultChatRequestParams = ChatRequestParameters.builder()
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.maxOutputTokens(chatModelConfig.maxOutputTokens())
.build();
return BedrockChatModel.builder()
.client(bedrockClient)
.modelId(chatModelConfig.modelId())
.defaultRequestParameters(defaultChatRequestParams)
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) {
return GitHubModelsChatModel.builder()
.gitHubToken(chatModelConfig.providerConfig().personalAccessToken())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
}
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
}

4
application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java

@ -113,7 +113,7 @@ public class EdgeEventSourcingListener {
return;
}
try {
if (EntityType.TENANT.equals(entityType) || EntityType.EDGE.equals(entityType)) {
if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) {
return;
}
log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event);
@ -227,7 +227,7 @@ public class EdgeEventSourcingListener {
break;
case TENANT:
return !event.getCreated();
case API_USAGE_STATE, EDGE:
case API_USAGE_STATE, EDGE, AI_MODEL:
return false;
case DOMAIN:
if (entity instanceof Domain domain) {

8
application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java

@ -58,8 +58,7 @@ public class RelatedEdgesSourcingListener {
log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event);
try {
switch (event.getActionType()) {
case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE ->
relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId());
case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId());
}
} catch (Exception e) {
log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e);
@ -67,7 +66,10 @@ public class RelatedEdgesSourcingListener {
});
}
@TransactionalEventListener(fallbackExecution = true)
@TransactionalEventListener(
fallbackExecution = true,
condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL"
)
public void handleEvent(DeleteEntityEvent<?> event) {
executorService.submit(() -> {
log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event);

4
application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java

@ -97,7 +97,7 @@ public abstract class AbstractTbEntityService {
return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
}
protected ListenableFuture<UUID> autoCommit(User user, EntityId entityId) throws Exception {
protected ListenableFuture<UUID> autoCommit(User user, EntityId entityId) {
if (vcService != null) {
return vcService.autoCommit(user, entityId);
} else {
@ -106,7 +106,7 @@ public abstract class AbstractTbEntityService {
}
}
protected ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds) throws Exception {
protected ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds) {
if (vcService != null) {
return vcService.autoCommit(user, entityType, entityIds);
} else {

80
application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java

@ -0,0 +1,80 @@
/**
* Copyright © 2016-2025 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.entitiy.ai;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
import static java.util.Objects.requireNonNullElseGet;
@Service
@TbCoreComponent
@RequiredArgsConstructor
class DefaultTbAiModelService extends AbstractTbEntityService implements TbAiModelService {
private final AiModelService aiModelService;
@Override
public AiModel save(AiModel model, User user) {
var actionType = model.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
var tenantId = user.getTenantId();
model.setTenantId(tenantId);
AiModel savedModel;
try {
savedModel = aiModelService.save(model);
autoCommit(user, savedModel.getId());
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(model.getId(), () -> emptyId(EntityType.AI_MODEL)), model, actionType, user, e);
throw e;
}
logEntityActionService.logEntityAction(tenantId, savedModel.getId(), savedModel, actionType, user);
return savedModel;
}
@Override
public boolean delete(AiModel model, User user) {
var actionType = ActionType.DELETED;
var tenantId = user.getTenantId();
var modelId = model.getId();
boolean deleted;
try {
deleted = aiModelService.deleteByTenantIdAndId(tenantId, modelId);
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, e, modelId.toString());
throw e;
}
if (deleted) {
logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, modelId.toString());
}
return deleted;
}
}

27
application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2025 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.entitiy.ai;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
public interface TbAiModelService {
AiModel save(AiModel model, User user);
boolean delete(AiModel model, User user);
}

23
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java

@ -583,17 +583,18 @@ public class DefaultTbClusterService implements TbClusterService {
TbQueueProducer<TbProtoQueueMsg<ToRuleEngineNotificationMsg>> toRuleEngineProducer = producerProvider.getRuleEngineNotificationsMsgProducer();
Set<String> tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE);
EntityType entityType = msg.getEntityId().getEntityType();
if (entityType.equals(EntityType.TENANT)
|| entityType.equals(EntityType.TENANT_PROFILE)
|| entityType.equals(EntityType.DEVICE_PROFILE)
|| (entityType.equals(EntityType.ASSET) && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| entityType.equals(EntityType.ASSET_PROFILE)
|| entityType.equals(EntityType.API_USAGE_STATE)
|| (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| entityType.equals(EntityType.ENTITY_VIEW)
|| entityType.equals(EntityType.NOTIFICATION_RULE)
|| entityType.equals(EntityType.CALCULATED_FIELD)
|| entityType.equals(EntityType.JOB)
if (entityType.isOneOf(
EntityType.TENANT,
EntityType.API_USAGE_STATE,
EntityType.ENTITY_VIEW,
EntityType.NOTIFICATION_RULE,
EntityType.CALCULATED_FIELD,
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE,
EntityType.JOB)
|| (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
|| (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED)
) {
TbQueueProducer<TbProtoQueueMsg<ToCoreNotificationMsg>> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer();
Set<String> tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE);

5
application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java

@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.Set;
public enum Resource {
ADMIN_SETTINGS(EntityType.ADMIN_SETTINGS),
ALARM(EntityType.ALARM),
DEVICE(EntityType.DEVICE),
@ -51,7 +52,8 @@ public enum Resource {
NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS,
JOB(EntityType.JOB);
JOB(EntityType.JOB),
AI_MODEL(EntityType.AI_MODEL);
private final Set<EntityType> entityTypes;
@ -75,4 +77,5 @@ public enum Resource {
}
throw new IllegalArgumentException("Unknown EntityType: " + entityType.name());
}
}

17
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.permission;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
@ -56,6 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.MOBILE_APP, tenantEntityPermissionChecker);
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.JOB, tenantEntityPermissionChecker);
put(Resource.AI_MODEL, aiModelPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {
@ -146,4 +149,18 @@ public class TenantAdminPermissions extends AbstractPermissions {
};
private static final PermissionChecker<AiModelId, AiModel> aiModelPermissionChecker = new PermissionChecker<>() {
@Override
public boolean hasPermission(SecurityUser user, Operation operation) {
return true;
}
@Override
public boolean hasPermission(SecurityUser user, Operation operation, AiModelId entityId, AiModel entity) {
return user.getTenantId().equals(entity.getTenantId());
}
};
}

3
application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java

@ -69,7 +69,8 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS
EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET,
EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE,
EntityType.ENTITY_VIEW, EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE,
EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE
EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE,
EntityType.AI_MODEL
);
@Override

36
application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2025 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.sync.ie.exporting.impl;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.sync.ie.EntityExportData;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.Set;
@Service
@TbCoreComponent
class AiModelExportService extends BaseEntityExportService<AiModelId, AiModel, EntityExportData<AiModel>> {
@Override
public Set<EntityType> getSupportedEntityTypes() {
return Set.of(EntityType.AI_MODEL);
}
}

77
application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java

@ -0,0 +1,77 @@
/**
* Copyright © 2016-2025 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.sync.ie.importing.impl;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.sync.ie.EntityExportData;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx;
@Service
@TbCoreComponent
@RequiredArgsConstructor
class AiModelImportService extends BaseEntityImportService<AiModelId, AiModel, EntityExportData<AiModel>> {
private final AiModelService aiModelService;
@Override
protected void setOwner(
TenantId tenantId,
AiModel model,
BaseEntityImportService<AiModelId, AiModel, EntityExportData<AiModel>>.IdProvider idProvider
) {
model.setTenantId(tenantId);
}
@Override
protected AiModel prepare(
EntitiesImportCtx ctx,
AiModel model,
AiModel oldModel,
EntityExportData<AiModel> exportData,
BaseEntityImportService<AiModelId, AiModel, EntityExportData<AiModel>>.IdProvider idProvider
) {
return model;
}
@Override
protected AiModel deepCopy(AiModel model) {
return new AiModel(model);
}
@Override
protected AiModel saveOrUpdate(
EntitiesImportCtx ctx,
AiModel model,
EntityExportData<AiModel> exportData,
BaseEntityImportService<AiModelId, AiModel, EntityExportData<AiModel>>.IdProvider idProvider,
CompareResult compareResult
) {
return aiModelService.save(model);
}
@Override
public EntityType getEntityType() {
return EntityType.AI_MODEL;
}
}

7
application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java

@ -114,9 +114,8 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
private final TbTransactionalCache<UUID, VersionControlTaskCacheEntry> taskCache;
private final VersionControlExecutor executor;
@SuppressWarnings("UnstableApiUsage")
@Override
public ListenableFuture<UUID> saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception {
public ListenableFuture<UUID> saveEntitiesVersion(User user, VersionCreateRequest request) {
checkBranchName(request.getBranch());
var pendingCommit = gitServiceQueue.prepareCommit(user, request);
DonAsynchron.withCallback(pendingCommit, commit -> {
@ -546,7 +545,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
}
@Override
public ListenableFuture<UUID> autoCommit(User user, EntityId entityId) throws Exception {
public ListenableFuture<UUID> autoCommit(User user, EntityId entityId) {
var repositorySettings = repositorySettingsService.get(user.getTenantId());
if (repositorySettings == null || repositorySettings.isReadOnly()) {
return Futures.immediateFuture(null);
@ -573,7 +572,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont
}
@Override
public ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds) throws Exception {
public ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds) {
var repositorySettings = repositorySettingsService.get(user.getTenantId());
if (repositorySettings == null || repositorySettings.isReadOnly()) {
return Futures.immediateFuture(null);

4
application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java

@ -69,9 +69,9 @@ public interface EntitiesVersionControlService {
ListenableFuture<Void> checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception;
ListenableFuture<UUID> autoCommit(User user, EntityId entityId) throws Exception;
ListenableFuture<UUID> autoCommit(User user, EntityId entityId);
ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds) throws Exception;
ListenableFuture<UUID> autoCommit(User user, EntityType entityType, List<UUID> entityIds);
ListenableFuture<EntityDataInfo> getEntityDataInfo(User user, EntityId entityId, String versionId);

44
application/src/main/resources/thingsboard.yml

@ -464,6 +464,14 @@ actors:
allow_system_sms_service: "${ACTORS_RULE_ALLOW_SYSTEM_SMS_SERVICE:true}"
# Specify thread pool size for external call service
external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:50}"
# Configuration for the thread pool that executes HTTP calls to AI provider APIs
ai-requests-thread-pool:
# The base name for threads
pool-name: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_NAME:ai-requests}"
# The maximum number of concurrent HTTP requests
pool-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_SIZE:50}"
# The maximum time in seconds to wait for active tasks to complete during graceful shutdown
termination-timeout-seconds: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_TERMINATION_TIMEOUT_SECONDS:60}"
chain:
# Errors for particular actors are persisted once per specified amount of milliseconds
error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}"
@ -648,6 +656,9 @@ cache:
trendzSettings:
timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL
maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled
aiModel:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL
maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:
@ -847,22 +858,23 @@ audit-log:
# Allowed values: OFF (disable), W (log write operations), RW (log read and write operations)
logging-level:
mask:
"device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation
"device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels.
"asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels.
"dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels.
"widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels.
"widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels.
"customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels.
"user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels.
"rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels.
"alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels.
"entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels.
"device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels.
"asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels.
"edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels.
"tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels.
"ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels.
"calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels.
"ai_model": "${AUDIT_LOG_MASK_AI_MODEL:W}" # AI model logging levels.
sink:
# Type of external sink. possible options: none, elasticsearch
type: "${AUDIT_LOG_SINK_TYPE:none}"

615
application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java

@ -0,0 +1,615 @@
/**
* Copyright © 2016-2025 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.controller;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class AiModelControllerTest extends AbstractControllerTest {
/* --- Save API tests --- */
@Test
public void saveAiModel_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception {
// GIVEN
loginSysAdmin();
AiModel model = constructValidOpenAiModel("Test model");
// WHEN
ResultActions result = doPost("/api/ai/model", model);
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void saveAiModel_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception {
// GIVEN
loginCustomerUser();
AiModel model = constructValidOpenAiModel("Test model");
// WHEN
ResultActions result = doPost("/api/ai/model", model);
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void saveAiModel_whenCreatingValidModelAsTenantAdmin_shouldSucceed() throws Exception {
// GIVEN
loginTenantAdmin();
AiModel model = constructValidOpenAiModel("Test model");
// WHEN
var savedModel = doPost("/api/ai/model", model, AiModel.class);
// THEN
// verify returned object
assertThat(savedModel.getId()).isNotNull();
assertThat(savedModel.getUuidId()).isNotNull().isNotEqualTo(EntityId.NULL_UUID);
assertThat(savedModel.getId().getEntityType()).isEqualTo(EntityType.AI_MODEL);
assertThat(savedModel.getCreatedTime()).isPositive();
assertThat(savedModel.getVersion()).isEqualTo(1);
assertThat(savedModel.getTenantId()).isEqualTo(tenantId);
assertThat(savedModel.getName()).isEqualTo("Test model");
assertThat(savedModel.getConfiguration()).isEqualTo(model.getConfiguration());
assertThat(savedModel.getExternalId()).isNull();
}
@Test
public void saveAiModel_whenUpdatingExistingModelAsTenantAdmin_shouldSucceed() throws Exception {
// GIVEN
loginTenantAdmin();
var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
var newModelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key-updated"))
.modelId("o4-mini")
.temperature(0.2)
.topP(0.4)
.frequencyPenalty(0.2)
.presencePenalty(0.5)
.maxOutputTokens(2000)
.timeoutSeconds(20)
.maxRetries(0)
.build();
model.setName("Test model updated");
model.setConfiguration(newModelConfig);
// WHEN
var updatedModel = doPost("/api/ai/model", model, AiModel.class);
// THEN
// verify returned object
assertThat(updatedModel.getId()).isEqualTo(model.getId());
assertThat(updatedModel.getCreatedTime()).isEqualTo(model.getCreatedTime());
assertThat(updatedModel.getVersion()).isEqualTo(2);
assertThat(updatedModel.getTenantId()).isEqualTo(tenantId);
assertThat(updatedModel.getName()).isEqualTo("Test model updated");
assertThat(updatedModel.getConfiguration()).isEqualTo(newModelConfig);
assertThat(updatedModel.getExternalId()).isNull();
}
/* --- Get by ID API tests --- */
@Test
public void getAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception {
// GIVEN
loginSysAdmin();
// WHEN
ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased());
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void getAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception {
// GIVEN
loginCustomerUser();
// WHEN
ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased());
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void getAiModelById_whenGettingExistingModelAsTenantAdmin_shouldReturnModel() throws Exception {
// GIVEN
loginTenantAdmin();
var saved = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
// WHEN
AiModel actual = doGet("/api/ai/model/" + saved.getId(), AiModel.class);
// THEN
assertThat(actual).isEqualTo(saved);
}
@Test
public void getAiModelById_whenGettingNonexistentModelAsTenantAdmin_shouldReturnNotFound() throws Exception {
// GIVEN
loginTenantAdmin();
var nonexistentModelId = new AiModelId(Uuids.timeBased());
// WHEN
ResultActions result = doGet("/api/ai/model/" + nonexistentModelId);
// THEN
result.andExpect(status().isNotFound())
.andExpect(statusReason(is("AI model with id [" + nonexistentModelId + "] is not found")));
}
/* --- Get paged API tests --- */
@Test
public void getAiModels_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception {
// GIVEN
loginSysAdmin();
// WHEN
ResultActions result = doGet("/api/ai/model?pageSize=10&page=0");
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void getAiModels_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception {
// GIVEN
loginCustomerUser();
// WHEN
ResultActions result = doGet("/api/ai/model?pageSize=10&page=0");
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void getAiModels_testPagination() throws Exception {
// GIVEN
loginTenantAdmin();
var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class);
var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class);
var model3 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 3"), AiModel.class);
var model4 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 4"), AiModel.class);
var model5 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 5"), AiModel.class);
// WHEN
PageData<AiModel> result = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(2, 1));
// THEN
assertThat(result.getData()).containsExactly(model3, model4);
assertThat(result.getTotalPages()).isEqualTo(3);
assertThat(result.getTotalElements()).isEqualTo(5);
assertThat(result.hasNext()).isTrue();
}
@Test
public void getAiModels_testSearchAndSortAppliedBeforePagination() throws Exception {
// GIVEN
loginTenantAdmin();
// Create 5 models: 3 with "Alpha" in name, 2 with "Beta" in name
var alpha1 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 1"), AiModel.class);
var beta1 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 1"), AiModel.class);
var alpha2 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 2"), AiModel.class);
var beta2 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 2"), AiModel.class);
var alpha3 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 3"), AiModel.class);
// WHEN
// Search for "Alpha", sort by name DESC, get the first page with size 2
PageData<AiModel> result = doGetTypedWithPageLink("/api/ai/model?",
new TypeReference<>() {},
new PageLink(2, 0, "Alpha", SortOrder.of("name", SortOrder.Direction.DESC)));
// THEN
// Should find only 3 "Alpha" models, sort them DESC (3, 2, 1), then return first 2
assertThat(result.getData()).containsExactly(alpha3, alpha2);
assertThat(result.getTotalPages()).isEqualTo(2); // One more "Alpha" model on the next page
assertThat(result.getTotalElements()).isEqualTo(3); // Only 3 models match "Alpha", not 5
assertThat(result.hasNext()).isTrue(); // One more "Alpha" model on the next page
}
@Test
public void getAiModels_testTextSearch() throws Exception {
// GIVEN
loginTenantAdmin();
var model1 = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 1")
.configuration(OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key"))
.modelId("o3-pro")
.build())
.build(), AiModel.class);
var model2 = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 2")
.configuration(GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-api-key"))
.modelId("gemini-2.5-flash")
.build())
.build(), AiModel.class);
var model3 = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 3")
.configuration(GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-api-key"))
.modelId("gemini-2.5-pro")
.build())
.build(), AiModel.class);
// WHEN
int pageSize = 10;
int page = 0;
SortOrder sortOrder = null;
PageData<AiModel> result1 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "google ai", sortOrder));
PageData<AiModel> result2 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "pro", sortOrder));
PageData<AiModel> result3 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "test", sortOrder));
PageData<AiModel> result4 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "anthropic", sortOrder));
// THEN
// should find google models
assertThat(result1.getData()).containsExactly(model2, model3);
assertThat(result1.getTotalPages()).isEqualTo(1);
assertThat(result1.getTotalElements()).isEqualTo(2);
assertThat(result1.hasNext()).isFalse();
// should find "o3-pro" and "gemini-2.5-pro" models
assertThat(result2.getData()).containsExactly(model1, model3);
assertThat(result2.getTotalPages()).isEqualTo(1);
assertThat(result2.getTotalElements()).isEqualTo(2);
assertThat(result2.hasNext()).isFalse();
// should find all models (all contain "Test" in their names)
assertThat(result3.getData()).containsExactly(model1, model2, model3);
assertThat(result3.getTotalPages()).isEqualTo(1);
assertThat(result3.getTotalElements()).isEqualTo(3);
assertThat(result3.hasNext()).isFalse();
// should find no models (nothing matches "anthropic")
assertThat(result4.getData()).isEmpty();
assertThat(result4.getTotalPages()).isEqualTo(0);
assertThat(result4.getTotalElements()).isEqualTo(0);
assertThat(result4.hasNext()).isFalse();
}
@Test
public void getAiModels_testSortingByCreatedTime() throws Exception {
// GIVEN
loginTenantAdmin();
var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class);
var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class);
// WHEN
int pageSize = 2;
int page = 0;
String textSearch = null;
PageData<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.ASC))
);
PageData<AiModel> resultDesc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.DESC))
);
// THEN
assertThat(resultAsc.getData()).containsExactly(model1, model2);
assertThat(resultAsc.getTotalPages()).isEqualTo(1);
assertThat(resultAsc.getTotalElements()).isEqualTo(2);
assertThat(resultAsc.hasNext()).isFalse();
assertThat(resultDesc.getData()).containsExactly(model2, model1);
assertThat(resultDesc.getTotalPages()).isEqualTo(1);
assertThat(resultDesc.getTotalElements()).isEqualTo(2);
assertThat(resultDesc.hasNext()).isFalse();
}
@Test
public void getAiModels_testSortingByName() throws Exception {
// GIVEN
loginTenantAdmin();
var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class);
var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class);
// WHEN
int pageSize = 2;
int page = 0;
String textSearch = null;
PageData<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.ASC))
);
PageData<AiModel> resultDesc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.DESC))
);
// THEN
assertThat(resultAsc.getData()).containsExactly(modelA, modelB);
assertThat(resultAsc.getTotalPages()).isEqualTo(1);
assertThat(resultAsc.getTotalElements()).isEqualTo(2);
assertThat(resultAsc.hasNext()).isFalse();
assertThat(resultDesc.getData()).containsExactly(modelB, modelA);
assertThat(resultDesc.getTotalPages()).isEqualTo(1);
assertThat(resultDesc.getTotalElements()).isEqualTo(2);
assertThat(resultDesc.hasNext()).isFalse();
}
@Test
public void getAiModels_testSortingByProvider() throws Exception {
// GIVEN
loginTenantAdmin();
var anthropicModel = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 1")
.configuration(AnthropicChatModelConfig.builder()
.providerConfig(new AnthropicProviderConfig("test-api-key"))
.modelId("claude-sonnet-4-0")
.build())
.build(), AiModel.class);
var geminiModel = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 2")
.configuration(GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-api-key"))
.modelId("gemini-2.5-pro")
.build())
.build(), AiModel.class);
// WHEN
int pageSize = 2;
int page = 0;
String textSearch = null;
PageData<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC))
);
PageData<AiModel> resultDesc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC))
);
// THEN
assertThat(resultAsc.getData()).containsExactly(anthropicModel, geminiModel);
assertThat(resultAsc.getTotalPages()).isEqualTo(1);
assertThat(resultAsc.getTotalElements()).isEqualTo(2);
assertThat(resultAsc.hasNext()).isFalse();
assertThat(resultDesc.getData()).containsExactly(geminiModel, anthropicModel);
assertThat(resultDesc.getTotalPages()).isEqualTo(1);
assertThat(resultDesc.getTotalElements()).isEqualTo(2);
assertThat(resultDesc.hasNext()).isFalse();
}
@Test
public void getAiModels_testSortingByModelId() throws Exception {
// GIVEN
loginTenantAdmin();
var modelA = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 1")
.configuration(AnthropicChatModelConfig.builder()
.providerConfig(new AnthropicProviderConfig("test-api-key"))
.modelId("model-a")
.build())
.build(), AiModel.class);
var modelB = doPost("/api/ai/model", AiModel.builder()
.tenantId(tenantId)
.name("Test model 2")
.configuration(GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-api-key"))
.modelId("model-b")
.build())
.build(), AiModel.class);
// WHEN
int pageSize = 2;
int page = 0;
String textSearch = null;
PageData<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.ASC))
);
PageData<AiModel> resultDesc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.DESC))
);
// THEN
assertThat(resultAsc.getData()).containsExactly(modelA, modelB);
assertThat(resultAsc.getTotalPages()).isEqualTo(1);
assertThat(resultAsc.getTotalElements()).isEqualTo(2);
assertThat(resultAsc.hasNext()).isFalse();
assertThat(resultDesc.getData()).containsExactly(modelB, modelA);
assertThat(resultDesc.getTotalPages()).isEqualTo(1);
assertThat(resultDesc.getTotalElements()).isEqualTo(2);
assertThat(resultDesc.hasNext()).isFalse();
}
@Test
public void getAiModels_testSortingByIdTieBreaker() throws Exception {
// GIVEN
loginTenantAdmin();
// Both models are from OpenAI and sorting will be done on provider
var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class);
var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class);
// WHEN
int pageSize = 2;
int page = 0;
String textSearch = null;
PageData<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC))
);
PageData<AiModel> resultDesc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC))
);
// THEN
// in both cases result should be the same since in case of ties (both models have OpenAI as provider, sorting by ID ascending is used)
assertThat(resultAsc.getData()).containsExactly(modelA, modelB);
assertThat(resultAsc.getTotalPages()).isEqualTo(1);
assertThat(resultAsc.getTotalElements()).isEqualTo(2);
assertThat(resultAsc.hasNext()).isFalse();
assertThat(resultDesc.getData()).containsExactly(modelA, modelB);
assertThat(resultDesc.getTotalPages()).isEqualTo(1);
assertThat(resultDesc.getTotalElements()).isEqualTo(2);
assertThat(resultDesc.hasNext()).isFalse();
}
/* --- Delete API tests --- */
@Test
public void deleteAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception {
// GIVEN
loginSysAdmin();
// WHEN
ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased());
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void deleteAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception {
// GIVEN
loginCustomerUser();
// WHEN
ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased());
// THEN
result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission)));
}
@Test
public void deleteAiModelById_whenDeletingExistingModelAsTenantAdmin_shouldSucceedAndReturnTrue() throws Exception {
// GIVEN
loginTenantAdmin();
var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
// WHEN
boolean deleted = doDelete("/api/ai/model/" + model.getId(), Boolean.class);
// THEN
assertThat(deleted).isTrue();
// verify model cannot be found anymore
doGet("/api/ai/model/" + model.getId())
.andExpect(status().isNotFound())
.andExpect(statusReason(is("AI model with id [" + model.getId() + "] is not found")));
}
@Test
public void deleteAiModelById_whenDeletingNonexistentModelAsTenantAdmin_shouldSucceedAndReturnFalse() throws Exception {
// GIVEN
loginTenantAdmin();
var nonexistentModelId = new AiModelId(Uuids.timeBased());
// WHEN
boolean deleted = doDelete("/api/ai/model/" + nonexistentModelId, Boolean.class);
// THEN
assertThat(deleted).isFalse();
}
private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key"))
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
.frequencyPenalty(0.1)
.presencePenalty(0.2)
.maxOutputTokens(1000)
.timeoutSeconds(60)
.maxRetries(2)
.build();
return AiModel.builder()
.tenantId(tenantId)
.name(name)
.configuration(modelConfig)
.build();
}
}

268
application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java

@ -0,0 +1,268 @@
/**
* Copyright © 2016-2025 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.entitiy.ai;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.User;
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.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.service.entitiy.TbLogEntityActionService;
import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService;
import java.util.UUID;
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.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
@ExtendWith(MockitoExtension.class)
class DefaultTbAiModelServiceTest {
@Mock
EntitiesVersionControlService vcServiceMock;
@Mock
AiModelService aiModelServiceMock;
@Mock
TbLogEntityActionService logEntityActionServiceMock;
@Spy
@InjectMocks
DefaultTbAiModelService service;
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID());
User user;
@BeforeEach
void setUp() {
user = new User();
user.setTenantId(tenantId);
service = new DefaultTbAiModelService(aiModelServiceMock);
ReflectionTestUtils.setField(service, "vcService", vcServiceMock);
ReflectionTestUtils.setField(service, "logEntityActionService", logEntityActionServiceMock);
}
@Test
void save_whenCreatingNewModel_shouldAutoCommitAndLogAddedActionAndUseTenantIdFromUser() {
// GIVEN
var modelToSave = AiModel.builder()
.name("Model to save")
.configuration(constructValidOpenAiModelConfig())
.build();
var savedModel = new AiModel(modelToSave);
savedModel.setId(new AiModelId(UUID.randomUUID()));
savedModel.setTenantId(user.getTenantId());
savedModel.setVersion(1L);
savedModel.setCreatedTime(System.currentTimeMillis());
given(aiModelServiceMock.save(modelToSave)).willReturn(savedModel);
// WHEN
AiModel result = service.save(modelToSave, user);
// THEN
assertThat(result).isEqualTo(savedModel);
then(aiModelServiceMock).should().save(modelToSave);
then(vcServiceMock).should().autoCommit(user, savedModel.getId());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, savedModel.getId(), savedModel, ActionType.ADDED, user);
}
@Test
void save_whenUpdatingExistingModel_shouldAutoCommitAndLogUpdatedAction() {
// GIVEN
var modelToUpdate = AiModel.builder()
.tenantId(tenantId)
.version(1L)
.name("Model to update")
.configuration(constructValidOpenAiModelConfig())
.build();
modelToUpdate.setId(new AiModelId(UUID.randomUUID()));
modelToUpdate.setCreatedTime(System.currentTimeMillis());
var updatedModel = new AiModel(modelToUpdate);
updatedModel.setVersion(2L);
updatedModel.setName("Updated model");
given(aiModelServiceMock.save(modelToUpdate)).willReturn(updatedModel);
// WHEN
AiModel result = service.save(modelToUpdate, user);
// THEN
assertThat(result).isEqualTo(updatedModel);
then(aiModelServiceMock).should().save(modelToUpdate);
then(vcServiceMock).should().autoCommit(user, updatedModel.getId());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, updatedModel.getId(), updatedModel, ActionType.UPDATED, user);
}
@Test
void save_whenCreatingNewModelThrowsException_shouldUseEmptyIdAndLogError() {
// GIVEN
var modelToSave = AiModel.builder()
.tenantId(tenantId)
.name("Model to save")
.configuration(constructValidOpenAiModelConfig())
.build();
var exception = new RuntimeException("Failed to save");
given(aiModelServiceMock.save(modelToSave)).willThrow(exception);
// WHEN-THEN
assertThatThrownBy(() -> service.save(modelToSave, user))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Failed to save");
then(aiModelServiceMock).should().save(modelToSave);
then(vcServiceMock).should(never()).autoCommit(any(), any());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, new AiModelId(EntityId.NULL_UUID), modelToSave, ActionType.ADDED, user, exception);
}
@Test
void save_whenUpdatingExistingModelThrowsException_shouldUseExistingModelIdAndLogError() {
// GIVEN
var modelToUpdate = AiModel.builder()
.tenantId(tenantId)
.version(1L)
.name("Model to update")
.configuration(constructValidOpenAiModelConfig())
.build();
modelToUpdate.setId(new AiModelId(UUID.randomUUID()));
modelToUpdate.setCreatedTime(System.currentTimeMillis());
var exception = new RuntimeException("Failed to save");
given(aiModelServiceMock.save(modelToUpdate)).willThrow(exception);
// WHEN-THEN
assertThatThrownBy(() -> service.save(modelToUpdate, user))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Failed to save");
then(aiModelServiceMock).should().save(modelToUpdate);
then(vcServiceMock).should(never()).autoCommit(any(), any());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToUpdate.getId(), modelToUpdate, ActionType.UPDATED, user, exception);
}
@Test
void delete_whenDeleteSuccessful_shouldLogDeletedAction() {
// GIVEN
var modelToDelete = AiModel.builder()
.tenantId(tenantId)
.version(1L)
.name("Model to delete")
.configuration(constructValidOpenAiModelConfig())
.build();
modelToDelete.setId(new AiModelId(UUID.randomUUID()));
modelToDelete.setCreatedTime(System.currentTimeMillis());
given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(true);
// WHEN
boolean result = service.delete(modelToDelete, user);
// THEN
assertThat(result).isTrue();
then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString());
}
@Test
void delete_whenDeleteReturnsFalse_shouldNotLogAction() {
// GIVEN
var modelToDelete = AiModel.builder()
.tenantId(tenantId)
.version(1L)
.name("Model to delete")
.configuration(constructValidOpenAiModelConfig())
.build();
modelToDelete.setId(new AiModelId(UUID.randomUUID()));
modelToDelete.setCreatedTime(System.currentTimeMillis());
given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(false);
// WHEN
boolean result = service.delete(modelToDelete, user);
// THEN
assertThat(result).isFalse();
then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId());
then(logEntityActionServiceMock).should(never()).logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString());
}
@Test
void delete_whenDeleteThrowsException_shouldLogError() {
// GIVEN
var modelToDelete = AiModel.builder()
.tenantId(tenantId)
.version(1L)
.name("Model to delete")
.configuration(constructValidOpenAiModelConfig())
.build();
modelToDelete.setId(new AiModelId(UUID.randomUUID()));
modelToDelete.setCreatedTime(System.currentTimeMillis());
var exception = new RuntimeException("Failed to delete");
given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willThrow(exception);
// WHEN-THEN
assertThatThrownBy(() -> service.delete(modelToDelete, user))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Failed to delete");
then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId());
then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, exception, modelToDelete.getId().toString());
}
private static AiModelConfig constructValidOpenAiModelConfig() {
return OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key"))
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
.frequencyPenalty(0.1)
.presencePenalty(0.2)
.maxOutputTokens(1000)
.timeoutSeconds(60)
.maxRetries(2)
.build();
}
}

42
common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java

@ -0,0 +1,42 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import com.google.common.util.concurrent.FluentFuture;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.entity.EntityDaoService;
import java.util.Optional;
public interface AiModelService extends EntityDaoService {
AiModel save(AiModel model);
Optional<AiModel> findAiModelById(TenantId tenantId, AiModelId modelId);
PageData<AiModel> findAiModelsByTenantId(TenantId tenantId, PageLink pageLink);
Optional<AiModel> findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId);
FluentFuture<Optional<AiModel>> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId);
}

4
common/data/pom.xml

@ -112,6 +112,10 @@
<artifactId>leshan-core</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-core</artifactId>
</dependency>
</dependencies>
<build>

6
common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java

@ -16,6 +16,7 @@
package org.thingsboard.server.common.data;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.media.Schema;
import org.thingsboard.server.common.data.id.IdBased;
import org.thingsboard.server.common.data.id.UUIDBased;
@ -41,6 +42,11 @@ public abstract class BaseData<I extends UUIDBased> extends IdBased<I> implement
this.createdTime = data.getCreatedTime();
}
@Schema(
description = "Entity creation timestamp in milliseconds since Unix epoch",
example = "1746028547220",
accessMode = Schema.AccessMode.READ_ONLY
)
public long getCreatedTime() {
return createdTime;
}

7
common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java

@ -15,7 +15,10 @@
*/
package org.thingsboard.server.common.data;
public class CacheConstants {
public final class CacheConstants {
private CacheConstants() {}
public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials";
public static final String RELATIONS_CACHE = "relations";
public static final String DEVICE_CACHE = "devices";
@ -36,6 +39,7 @@ public class CacheConstants {
public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings";
public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications";
public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings";
public static final String AI_MODEL_CACHE = "aiModel";
public static final String ASSET_PROFILE_CACHE = "assetProfiles";
public static final String ATTRIBUTES_CACHE = "attributes";
@ -54,4 +58,5 @@ public class CacheConstants {
public static final String ALARM_TYPES_CACHE = "alarmTypes";
public static final String QR_CODE_SETTINGS_CACHE = "qrCodeSettings";
public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey";
}

8
common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java

@ -63,7 +63,13 @@ public enum EntityType {
CALCULATED_FIELD(39),
CALCULATED_FIELD_LINK(40),
JOB(41),
ADMIN_SETTINGS(42);
ADMIN_SETTINGS(42),
AI_MODEL(43, "ai_model") {
@Override
public String getNormalName() {
return "AI model";
}
};
@Getter
private final int protoNumber; // Corresponds to EntityTypeProto

102
common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java

@ -0,0 +1,102 @@
/**
* Copyright © 2016-2025 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.data.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.BaseData;
import org.thingsboard.server.common.data.ExportableEntity;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.HasVersion;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import java.io.Serial;
@Data
@Builder
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public final class AiModel extends BaseData<AiModelId> implements HasTenantId, HasVersion, ExportableEntity<AiModelId> {
@Serial
private static final long serialVersionUID = 9017108678716011604L;
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "JSON object representing the ID of the tenant associated with this AI model",
example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d"
)
private TenantId tenantId;
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "Version of the AI model record; increments automatically whenever the record is changed",
example = "7",
defaultValue = "1"
)
private Long version;
@NotBlank
@NoNullChar
@Length(min = 1, max = 255)
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Display name for this AI model configuration; not the technical model identifier",
example = "Fast and cost-efficient model"
)
private String name;
@NotNull
@Valid
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Configuration of the AI model"
)
private AiModelConfig configuration;
private AiModelId externalId;
public AiModel() {}
public AiModel(AiModelId id) {
super(id);
}
public AiModel(AiModel model) {
super(model.getId());
createdTime = model.getCreatedTime();
tenantId = model.getTenantId();
version = model.getVersion();
name = model.getName();
configuration = model.getConfiguration();
externalId = model.getExternalId() == null ? null : new AiModelId(model.getExternalId().getId());
}
}

79
common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java

@ -0,0 +1,79 @@
/**
* Copyright © 2016-2025 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.data.ai.dto;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import java.util.ArrayList;
import java.util.List;
public record TbChatRequest(
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "A system-level instruction that frames the user's input, setting the persona, tone, and constraints for the generated response",
example = "You are a helpful assistant. Only output valid JSON."
)
String systemMessage,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "The actual user prompt that will be answered by the AI model"
)
@NotNull @Valid
TbUserMessage userMessage,
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Configuration of the AI chat model that should execute the request"
)
@NotNull @Valid
AiChatModelConfig<?> chatModelConfig
) {
public ChatRequest toLangChainChatRequest() {
return ChatRequest.builder()
.messages(getLangChainMessages())
.build();
}
private List<ChatMessage> getLangChainMessages() {
List<ChatMessage> messages = new ArrayList<>(2);
if (systemMessage != null) {
messages.add(SystemMessage.from(systemMessage));
}
List<Content> langChainContents = userMessage.contents().stream()
.map(TbContent::toLangChainContent)
.toList();
messages.add(UserMessage.from(langChainContents));
return messages;
}
}

68
common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java

@ -0,0 +1,68 @@
/**
* Copyright © 2016-2025 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.data.ai.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.swagger.v3.oas.annotations.media.Schema;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "status",
include = JsonTypeInfo.As.PROPERTY,
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TbChatResponse.Success.class, name = "SUCCESS"),
@JsonSubTypes.Type(value = TbChatResponse.Failure.class, name = "FAILURE")
})
public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatResponse.Failure {
@Schema(
description = "Indicates whether the request was successful or not",
example = "SUCCESS"
)
String getStatus();
record Success(
@Schema(description = "The text content generated by the model")
String generatedContent
) implements TbChatResponse {
@Override
@Schema(example = "SUCCESS")
public String getStatus() {
return "SUCCESS";
}
}
record Failure(
@Schema(
description = "A string containing details about the failure"
)
String errorDetails
) implements TbChatResponse {
@Override
@Schema(example = "FAILURE")
public String getStatus() {
return "FAILURE";
}
}
}

73
common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java

@ -0,0 +1,73 @@
/**
* Copyright © 2016-2025 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.data.ai.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import static org.thingsboard.server.common.data.ai.dto.TbContent.TbTextContent;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "contentType",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TbTextContent.class, name = "TEXT")
})
public sealed interface TbContent permits TbTextContent {
TbContentType contentType();
Content toLangChainContent();
enum TbContentType {
TEXT
}
@Schema(
description = "Text-based content part of a user's prompt"
)
record TbTextContent(
@NotBlank
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "The text content",
example = "What is the weather like in Kyiv today?"
)
String text
) implements TbContent {
@Override
public TbContentType contentType() {
return TbContentType.TEXT;
}
@Override
public Content toLangChainContent() {
return TextContent.from(text);
}
}
}

32
common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2025 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.data.ai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record TbUserMessage(
@NotEmpty
@Valid
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "A list of content parts that make up the complete user prompt"
)
List<TbContent> contents
) {}

78
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java

@ -0,0 +1,78 @@
/**
* Copyright © 2016-2025 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.data.ai.model;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
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.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
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.OpenAiProviderConfig;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "provider",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "OPENAI"),
@JsonSubTypes.Type(value = AzureOpenAiChatModelConfig.class, name = "AZURE_OPENAI"),
@JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "GOOGLE_AI_GEMINI"),
@JsonSubTypes.Type(value = GoogleVertexAiGeminiChatModelConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"),
@JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"),
@JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS")
})
public interface AiModelConfig {
AiProvider provider();
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
property = "provider"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"),
@JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"),
@JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"),
@JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"),
@JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"),
@JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"),
@JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"),
@JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS")
})
AiProviderConfig providerConfig();
AiModelType modelType();
}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.model;
public enum AiModelType {
CHAT
}

47
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.langchain4j.model.chat.ChatModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.model.AiModelType;
public sealed interface AiChatModelConfig<C extends AiChatModelConfig<C>> extends AiModelConfig
permits
OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig,
GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig,
AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig {
ChatModel configure(Langchain4jChatModelConfigurer configurer);
@Override
@JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY)
default AiModelType modelType() {
return AiModelType.CHAT;
}
Integer timeoutSeconds();
Integer maxRetries();
C withTimeoutSeconds(Integer timeoutSeconds);
C withMaxRetries(Integer maxRetries);
boolean supportsJsonMode();
}

56
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java

@ -0,0 +1,56 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
@Builder
public record AmazonBedrockChatModelConfig(
@NotNull @Valid AmazonBedrockProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AmazonBedrockChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.AMAZON_BEDROCK;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return false;
}
}

57
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java

@ -0,0 +1,57 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
@Builder
public record AnthropicChatModelConfig(
@NotNull @Valid AnthropicProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AnthropicChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.ANTHROPIC;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return false;
}
}

58
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
@Builder
public record AzureOpenAiChatModelConfig(
@NotNull @Valid AzureOpenAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AzureOpenAiChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.AZURE_OPENAI;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return true;
}
}

58
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
@Builder
public record GitHubModelsChatModelConfig(
@NotNull @Valid GitHubModelsProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GitHubModelsChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.GITHUB_MODELS;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return false;
}
}

59
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java

@ -0,0 +1,59 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
@Builder
public record GoogleAiGeminiChatModelConfig(
@NotNull @Valid GoogleAiGeminiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GoogleAiGeminiChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.GOOGLE_AI_GEMINI;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return true;
}
}

59
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java

@ -0,0 +1,59 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
@Builder
public record GoogleVertexAiGeminiChatModelConfig(
@NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@PositiveOrZero Integer topK,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GoogleVertexAiGeminiChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.GOOGLE_VERTEX_AI_GEMINI;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return true;
}
}

38
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java

@ -0,0 +1,38 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
public interface Langchain4jChatModelConfigurer {
ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig);
ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig);
ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig);
ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig);
ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig);
ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig);
ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig);
ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig);
}

58
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
@Builder
public record MistralAiChatModelConfig(
@NotNull @Valid MistralAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<MistralAiChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.MISTRAL_AI;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return true;
}
}

58
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java

@ -0,0 +1,58 @@
/**
* Copyright © 2016-2025 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.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Builder;
import lombok.With;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@Builder
public record OpenAiChatModelConfig(
@NotNull @Valid OpenAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
@Positive Integer maxOutputTokens,
@With @Positive Integer timeoutSeconds,
@With @PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<OpenAiChatModelConfig> {
@Override
public AiProvider provider() {
return AiProvider.OPENAI;
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public boolean supportsJsonMode() {
return true;
}
}

29
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
public enum AiProvider {
OPENAI,
AZURE_OPENAI,
GOOGLE_AI_GEMINI,
GOOGLE_VERTEX_AI_GEMINI,
MISTRAL_AI,
ANTHROPIC,
AMAZON_BEDROCK,
GITHUB_MODELS
}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
public sealed interface AiProviderConfig
permits
OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig,
GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig,
AmazonBedrockProviderConfig, GitHubModelsProviderConfig {}

24
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record AmazonBedrockProviderConfig(
@NotNull String region,
@NotNull String accessKeyId,
@NotNull String secretAccessKey
) implements AiProviderConfig {}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record AnthropicProviderConfig(
@NotNull String apiKey
) implements AiProviderConfig {}

24
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record AzureOpenAiProviderConfig(
@NotNull String endpoint,
String serviceVersion,
@NotNull String apiKey
) implements AiProviderConfig {}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record GitHubModelsProviderConfig(
@NotNull String personalAccessToken
) implements AiProviderConfig {}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record GoogleAiGeminiProviderConfig(
@NotNull String apiKey
) implements AiProviderConfig {}

26
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record GoogleVertexAiGeminiProviderConfig(
@NotBlank String fileName, // not used on BE, but needed for UI
@NotNull String projectId,
@NotNull String location,
@NotNull String serviceAccountKey
) implements AiProviderConfig {}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record MistralAiProviderConfig(
@NotNull String apiKey
) implements AiProviderConfig {}

22
common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2025 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.data.ai.provider;
import jakarta.validation.constraints.NotNull;
public record OpenAiProviderConfig(
@NotNull String apiKey
) implements AiProviderConfig {}

51
common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java

@ -0,0 +1,51 @@
/**
* Copyright © 2016-2025 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.data.id;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import org.thingsboard.server.common.data.EntityType;
import java.io.Serial;
import java.util.UUID;
public final class AiModelId extends UUIDBased implements EntityId {
@Serial
private static final long serialVersionUID = 3021036138554389754L;
@JsonCreator
public AiModelId(@JsonProperty("id") UUID id) {
super(id);
}
@Override
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "Entity type of the AI model",
example = "AI_MODEL",
allowableValues = "AI_MODEL"
)
public EntityType getEntityType() {
return EntityType.AI_MODEL;
}
public static AiModelId fromString(String uuid) {
return new AiModelId(UUID.fromString(uuid));
}
}

4
common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java

@ -66,7 +66,7 @@ public class EntityIdFactory {
case API_USAGE_STATE -> new ApiUsageStateId(uuid);
case TB_RESOURCE -> new TbResourceId(uuid);
case OTA_PACKAGE -> new OtaPackageId(uuid);
case EDGE -> EdgeId.fromUUID(uuid);
case EDGE -> new EdgeId(uuid);
case RPC -> new RpcId(uuid);
case QUEUE -> new QueueId(uuid);
case NOTIFICATION_TARGET -> new NotificationTargetId(uuid);
@ -83,7 +83,7 @@ public class EntityIdFactory {
case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid);
case JOB -> new JobId(uuid);
case ADMIN_SETTINGS -> new AdminSettingsId(uuid);
default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!");
case AI_MODEL -> new AiModelId(uuid);
};
}

7
common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.OtaPackage;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.notification.rule.NotificationRule;
@ -60,8 +61,8 @@ import java.lang.annotation.Target;
@Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class),
@Type(name = "NOTIFICATION_RULE", value = NotificationRule.class),
@Type(name = "TB_RESOURCE", value = TbResource.class),
@Type(name = "OTA_PACKAGE", value = OtaPackage.class)
@Type(name = "OTA_PACKAGE", value = OtaPackage.class),
@Type(name = "AI_MODEL", value = AiModel.class)
})
@JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true)
public @interface JsonTbEntity {
}
public @interface JsonTbEntity {}

39
common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2025 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.data.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoNullChar {
String message() default "should not contain 0x00 symbol";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

39
common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2025 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.data.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidJsonSchema {
String message() default "must conform to the Draft 2020-12 meta-schema";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

22
common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.common.msg;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.io.Serializable;
@ -23,9 +24,6 @@ import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Created by ashvayka on 13.01.18.
*/
@Data
public final class TbMsgMetaData implements Serializable {
@ -34,7 +32,7 @@ public final class TbMsgMetaData implements Serializable {
private final Map<String, String> data;
public TbMsgMetaData() {
this.data = new ConcurrentHashMap<>();
data = new ConcurrentHashMap<>();
}
public TbMsgMetaData(Map<String, String> data) {
@ -46,24 +44,30 @@ public final class TbMsgMetaData implements Serializable {
* Internal constructor to create immutable TbMsgMetaData.EMPTY
* */
private TbMsgMetaData(int ignored) {
this.data = Collections.emptyMap();
data = Collections.emptyMap();
}
public String getValue(String key) {
return this.data.get(key);
return data.get(key);
}
public void putValue(String key, String value) {
if (key != null && value != null) {
this.data.put(key, value);
data.put(key, value);
}
}
public Map<String, String> values() {
return new HashMap<>(this.data);
return new HashMap<>(data);
}
public TbMsgMetaData copy() {
return new TbMsgMetaData(this.data);
return new TbMsgMetaData(data);
}
@JsonIgnore
public boolean isEmpty() {
return data == null || data.isEmpty();
}
}

1
common/proto/src/main/proto/queue.proto

@ -65,6 +65,7 @@ enum EntityTypeProto {
CALCULATED_FIELD_LINK = 40;
JOB = 41;
ADMIN_SETTINGS = 42;
AI_MODEL = 43;
}
enum ApiUsageRecordKeyProto {

5
common/util/pom.xml

@ -114,7 +114,10 @@
<dependency>
<groupId>net.objecthunter</groupId>
<artifactId>exp4j</artifactId>
<version>${exp4j.version}</version>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>

24
common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java

@ -26,11 +26,13 @@ import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Clock;
import java.util.Base64;
import java.util.Iterator;
@Slf4j
public final class AzureIotHubUtil {
private static final String BASE_DIR_PATH = System.getProperty("user.dir");
private static final String APP_DIR = "application";
private static final String SRC_DIR = "src";
@ -52,41 +54,37 @@ public final class AzureIotHubUtil {
}
}
private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60;
private static final long ONE_SECOND_IN_MILLISECONDS = 1000;
private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; // one year
private static final String SAS_TOKEN_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s";
private static final String USERNAME_FORMAT = "%s/%s/?api-version=2018-06-30";
private AzureIotHubUtil() {
}
private AzureIotHubUtil() {}
public static String buildUsername(String host, String deviceId) {
return String.format(USERNAME_FORMAT, host, deviceId);
}
public static String buildSasToken(String host, String sasKey) {
public static String buildSasToken(String host, String sasKey, Clock clock) {
try {
final String targetUri = URLEncoder.encode(host.toLowerCase(), "UTF-8");
final long expiryTime = buildExpiresOn();
final String targetUri = URLEncoder.encode(host.toLowerCase(), StandardCharsets.UTF_8);
final long expiryTime = buildExpiresOn(clock);
String toSign = targetUri + "\n" + expiryTime;
byte[] keyBytes = Base64.getDecoder().decode(sasKey.getBytes(StandardCharsets.UTF_8));
SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(signingKey);
byte[] rawHmac = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8));
String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), "UTF-8");
String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), StandardCharsets.UTF_8);
return String.format(SAS_TOKEN_FORMAT, targetUri, signature, expiryTime);
} catch (Exception e) {
throw new RuntimeException("Failed to build SAS token!!!", e);
throw new RuntimeException("Failed to build SAS token!", e);
}
}
private static long buildExpiresOn() {
long expiresOnDate = System.currentTimeMillis();
expiresOnDate += SAS_TOKEN_VALID_SECS * ONE_SECOND_IN_MILLISECONDS;
return expiresOnDate / ONE_SECOND_IN_MILLISECONDS;
private static long buildExpiresOn(Clock clock) {
return clock.instant().plusSeconds(SAS_TOKEN_VALID_SECS).getEpochSecond();
}
public static String getDefaultCaCert() {

45
common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java

@ -0,0 +1,45 @@
/**
* Copyright © 2016-2025 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.common.util;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SchemaId;
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import java.util.Set;
public final class JsonSchemaUtils {
private JsonSchemaUtils() {}
/**
* Validates that the provided ObjectNode is a valid JSON Schema (Draft 2020-12).
*
* @param schemaNode the JSON Schema document as an ObjectNode
* @return true if the schema is well-formed, false otherwise
*/
public static boolean isValidJsonSchema(ObjectNode schemaNode) {
Set<ValidationMessage> errors = JsonSchemaFactory
.getInstance(SpecVersion.VersionFlag.V202012)
.getSchema(SchemaLocation.of(SchemaId.V202012))
.validate(schemaNode);
return errors.isEmpty();
}
}

23
dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java

@ -43,10 +43,9 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
public abstract class DaoUtil {
public final class DaoUtil {
private DaoUtil() {
}
private DaoUtil() {}
public static <T> PageData<T> toPageData(Page<? extends ToData<T>> page) {
List<T> data = convertDataList(page.getContent());
@ -98,17 +97,17 @@ public abstract class DaoUtil {
return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), pageLink.toSort(sortOrders, columnMap, addDefaultSorting));
}
public static <T> List<T> convertDataList(Collection<? extends ToData<T>> toDataList) {
List<T> list = Collections.emptyList();
if (toDataList != null && !toDataList.isEmpty()) {
list = new ArrayList<>();
for (ToData<T> object : toDataList) {
if (object != null) {
list.add(object.toData());
}
public static <T> List<T> convertDataList(Collection<? extends ToData<T>> toConvert) {
if (CollectionUtils.isEmpty(toConvert)) {
return Collections.emptyList();
}
List<T> converted = new ArrayList<>(toConvert.size());
for (ToData<T> object : toConvert) {
if (object != null) {
converted.add(object.toData());
}
}
return list;
return converted;
}
public static <T> T getData(ToData<T> data) {

45
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java

@ -0,0 +1,45 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import org.thingsboard.server.common.data.ai.AiModel;
import static java.util.Objects.requireNonNull;
import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Deleted;
import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Saved;
sealed interface AiModelCacheEvictEvent permits Saved, Deleted {
AiModelCacheKey cacheKey();
record Saved(AiModelCacheKey cacheKey, AiModel savedModel) implements AiModelCacheEvictEvent {
public Saved {
requireNonNull(cacheKey);
requireNonNull(savedModel);
}
}
record Deleted(AiModelCacheKey cacheKey) implements AiModelCacheEvictEvent {
public Deleted {
requireNonNull(cacheKey);
}
}
}

57
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java

@ -0,0 +1,57 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.thingsboard.server.cache.VersionedCacheKey;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.UUID;
import static java.util.Objects.requireNonNull;
record AiModelCacheKey(UUID tenantId, UUID modelId) implements VersionedCacheKey {
AiModelCacheKey {
requireNonNull(tenantId);
requireNonNull(modelId);
if (TenantId.SYS_TENANT_ID.getId().equals(tenantId)) {
throw new IllegalArgumentException("Tenant ID must not be the system tenant ID");
}
if (EntityId.NULL_UUID.equals(modelId)) {
throw new IllegalArgumentException("Model ID must not be reserved null UUID");
}
}
static AiModelCacheKey of(TenantId tenantId, AiModelId modelId) {
return new AiModelCacheKey(tenantId.getId(), modelId.getId());
}
@Override
public boolean isVersioned() {
return true;
}
@NonNull
@Override
public String toString() {
return /* cache name */ "_" + tenantId + "_" + modelId;
}
}

33
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import org.thingsboard.server.cache.VersionedCaffeineTbCache;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.ai.AiModel;
@Component("AiModelCache")
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
class AiModelCaffeineCache extends VersionedCaffeineTbCache<AiModelCacheKey, AiModel> {
AiModelCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.AI_MODEL_CACHE);
}
}

37
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ExportableEntityDao;
import org.thingsboard.server.dao.TenantEntityDao;
import java.util.Optional;
import java.util.Set;
public interface AiModelDao extends TenantEntityDao<AiModel>, ExportableEntityDao<AiModelId, AiModel> {
Optional<AiModel> findByTenantIdAndId(TenantId tenantId, AiModelId modelId);
boolean deleteById(TenantId tenantId, AiModelId modelId);
Set<AiModelId> deleteByTenantId(TenantId tenantId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId);
}

36
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java

@ -0,0 +1,36 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.stereotype.Component;
import org.thingsboard.server.cache.CacheSpecsMap;
import org.thingsboard.server.cache.TBRedisCacheConfiguration;
import org.thingsboard.server.cache.TbJsonRedisSerializer;
import org.thingsboard.server.cache.VersionedRedisTbCache;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.ai.AiModel;
@Component("AiModelCache")
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis")
class AiModelRedisCache extends VersionedRedisTbCache<AiModelCacheKey, AiModel> {
AiModelRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
super(CacheConstants.AI_MODEL_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModel.class));
}
}

149
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java

@ -0,0 +1,149 @@
/**
* Copyright © 2016-2025 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.dao.ai;
import com.google.common.util.concurrent.FluentFuture;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.entity.CachedVersionedEntityService;
import org.thingsboard.server.dao.model.sql.AiModelEntity;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import java.util.Optional;
import java.util.Set;
import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@Service
@RequiredArgsConstructor
class AiModelServiceImpl extends CachedVersionedEntityService<AiModelCacheKey, AiModel, AiModelCacheEvictEvent> implements AiModelService {
private final DataValidator<AiModel> aiModelValidator;
private final JpaExecutorService jpaExecutor;
private final AiModelDao aiModelDao;
@Override
@TransactionalEventListener
public void handleEvictEvent(AiModelCacheEvictEvent event) {
var cacheKey = event.cacheKey();
if (event instanceof AiModelCacheEvictEvent.Saved savedEvent) {
cache.put(cacheKey, savedEvent.savedModel());
} else if (event instanceof AiModelCacheEvictEvent.Deleted) {
cache.evict(cacheKey);
} else {
throw new UnsupportedOperationException("Unsupported event type: " + event.getClass().getSimpleName());
}
}
@Override
@Transactional
public AiModel save(AiModel model) {
aiModelValidator.validate(model, AiModel::getTenantId);
AiModel savedModel;
try {
savedModel = aiModelDao.saveAndFlush(model.getTenantId(), model);
} catch (Exception e) {
checkConstraintViolation(e,
"ai_model_name_unq_key", "AI model with such name already exist!",
"ai_model_external_id_unq_key", "AI model with such external ID already exists!");
throw e;
}
var cacheKey = AiModelCacheKey.of(savedModel.getTenantId(), savedModel.getId());
publishEvictEvent(new AiModelCacheEvictEvent.Saved(cacheKey, savedModel));
return savedModel;
}
@Override
public Optional<AiModel> findAiModelById(TenantId tenantId, AiModelId modelId) {
return Optional.ofNullable(aiModelDao.findById(tenantId, modelId.getId()));
}
@Override
public PageData<AiModel> findAiModelsByTenantId(TenantId tenantId, PageLink pageLink) {
validatePageLink(pageLink, AiModelEntity.ALLOWED_SORT_PROPERTIES);
return aiModelDao.findAllByTenantId(tenantId, pageLink);
}
@Override
public Optional<AiModel> findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId) {
var cacheKey = AiModelCacheKey.of(tenantId, modelId);
return Optional.ofNullable(cache.get(cacheKey, () -> aiModelDao.findByTenantIdAndId(tenantId, modelId).orElse(null)));
}
@Override
public FluentFuture<Optional<AiModel>> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId) {
return FluentFuture.from(jpaExecutor.submit(() -> findAiModelByTenantIdAndId(tenantId, modelId)));
}
@Override
@Transactional
public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) {
return deleteByTenantIdAndIdInternal(tenantId, modelId);
}
@Override
public Optional<HasId<?>> findEntity(TenantId tenantId, EntityId entityId) {
return findAiModelByTenantIdAndId(tenantId, (AiModelId) entityId)
.map(model -> model); // necessary to cast to HasId<?>
}
@Override
public long countByTenantId(TenantId tenantId) {
return aiModelDao.countByTenantId(tenantId);
}
@Override
@Transactional
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteByTenantIdAndIdInternal(tenantId, new AiModelId(id.getId()));
}
private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelId modelId) {
boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, modelId);
if (deleted) {
publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, modelId)));
}
return deleted;
}
@Override
@Transactional
public void deleteByTenantId(TenantId tenantId) {
Set<AiModelId> deleted = aiModelDao.deleteByTenantId(tenantId);
deleted.forEach(id -> publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, id))));
}
@Override
public EntityType getEntityType() {
return EntityType.AI_MODEL;
}
}

2
dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java

@ -47,7 +47,7 @@ public class CleanUpService {
private final Set<EntityType> skippedEntities = EnumSet.of(
EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE,
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE
EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL
);
@TransactionalEventListener(fallbackExecution = true) // after transaction commit

8
dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java

@ -751,6 +751,14 @@ public class ModelConstants {
public static final String JOB_CONFIGURATION_PROPERTY = "configuration";
public static final String JOB_RESULT_PROPERTY = "result";
/**
* AI model constants.
*/
public static final String AI_MODEL_TABLE_NAME = "ai_model";
public static final String AI_MODEL_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN;
public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY;
public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration";
protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN};
protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)};

110
dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java

@ -0,0 +1,110 @@
/**
* Copyright © 2016-2025 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.dao.model.sql;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.Type;
import org.hibernate.proxy.HibernateProxy;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.model.BaseVersionedEntity;
import org.thingsboard.server.dao.model.ModelConstants;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@Getter
@Setter
@ToString
@Entity
@Table(name = ModelConstants.AI_MODEL_TABLE_NAME)
public class AiModelEntity extends BaseVersionedEntity<AiModel> {
public static final Map<String, String> COLUMN_MAP = Map.of(
"createdTime", "created_time",
"provider", "(configuration ->> 'provider')",
"modelId", "(configuration ->> 'modelId')"
);
public static final Set<String> ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet(
new LinkedHashSet<>(List.of("createdTime", "name", "provider", "modelId"))
);
@Column(name = ModelConstants.AI_MODEL_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID")
private UUID tenantId;
@Column(name = ModelConstants.AI_MODEL_NAME_COLUMN_NAME, nullable = false)
private String name;
@Type(JsonBinaryType.class)
@Column(name = ModelConstants.AI_MODEL_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB")
private AiModelConfig configuration;
@Column(name = ModelConstants.EXTERNAL_ID_PROPERTY, columnDefinition = "UUID")
private UUID externalId;
public AiModelEntity() {}
public AiModelEntity(AiModel aiModel) {
super(aiModel);
tenantId = getTenantUuid(aiModel.getTenantId());
name = aiModel.getName();
configuration = aiModel.getConfiguration();
externalId = getUuid(aiModel.getExternalId());
}
@Override
public AiModel toData() {
var model = new AiModel(new AiModelId(id));
model.setCreatedTime(createdTime);
model.setVersion(version);
model.setTenantId(TenantId.fromUUID(tenantId));
model.setName(name);
model.setConfiguration(configuration);
model.setExternalId(getEntityId(externalId, AiModelId::new));
return model;
}
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
AiModelEntity that = (AiModelEntity) o;
return getId() != null && Objects.equals(getId(), that.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
}
}

10
dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java

@ -21,7 +21,6 @@ import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.metadata.ConstraintDescriptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.HibernateValidatorConfiguration;
@ -32,15 +31,16 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import org.thingsboard.server.common.data.validation.NoXss;
import org.thingsboard.server.common.data.validation.RateLimit;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
import org.thingsboard.server.dao.exception.DataValidationException;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Configuration
public class ConstraintValidator {
@ -88,7 +88,9 @@ public class ConstraintValidator {
ConstraintMapping constraintMapping = getCustomConstraintMapping();
validatorConfiguration.addMapping(constraintMapping);
fieldsValidator = validatorConfiguration.buildValidatorFactory().getValidator();
try (var validatorFactory = validatorConfiguration.buildValidatorFactory()) {
fieldsValidator = validatorFactory.getValidator();
}
}
@Bean
@ -105,6 +107,8 @@ public class ConstraintValidator {
constraintMapping.constraintDefinition(NoXss.class).validatedBy(NoXssValidator.class);
constraintMapping.constraintDefinition(Length.class).validatedBy(StringLengthValidator.class);
constraintMapping.constraintDefinition(RateLimit.class).validatedBy(RateLimitValidator.class);
constraintMapping.constraintDefinition(NoNullChar.class).validatedBy(NoNullCharValidator.class);
constraintMapping.constraintDefinition(ValidJsonSchema.class).validatedBy(JsonSchemaValidator.class);
return constraintMapping;
}

31
dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2025 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.dao.service;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.thingsboard.common.util.JsonSchemaUtils;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
public final class JsonSchemaValidator implements ConstraintValidator<ValidJsonSchema, ObjectNode> {
@Override
public boolean isValid(ObjectNode schema, ConstraintValidatorContext context) {
return schema == null || JsonSchemaUtils.isValidJsonSchema(schema);
}
}

29
dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 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.dao.service;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.thingsboard.server.common.data.validation.NoNullChar;
public final class NoNullCharValidator implements ConstraintValidator<NoNullChar, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || !value.contains("\u0000");
}
}

56
dao/src/main/java/org/thingsboard/server/dao/service/Validator.java

@ -26,11 +26,14 @@ import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.regex.Pattern;
public class Validator {
public final class Validator {
private Validator() {}
public static final Pattern PROPERTY_PATTERN = Pattern.compile("^[\\p{L}0-9_-]+$"); // Unicode letters, numbers, '_' and '-' allowed
@ -204,22 +207,61 @@ public class Validator {
}
/**
* This method validate <code>PageLink</code> page link. If pageLink is invalid than throw
* <code>IncorrectParameterException</code> exception
* Validates the specified PageLink object delegating to {@link #validatePageLink(PageLink, Set)}
* with no restrictions on allowed sort properties.
*
* @param pageLink the page link
* @param pageLink the PageLink object to validate
* @throws IncorrectParameterException if the pageLink is null, has invalid page size,
* invalid page number, or invalid sort property
* @see #validatePageLink(PageLink, Set)
*/
public static void validatePageLink(PageLink pageLink) {
validatePageLink(pageLink, null);
}
/**
* Validates the specified PageLink object ensuring that:
* <ul>
* <li>The PageLink object is not null</li>
* <li>The page size is greater than zero</li>
* <li>The page number is non-negative</li>
* <li>If sorting is specified, the sort property is valid and allowed</li>
* </ul>
*
* <p>When {@code allowedSortProperties} is provided, the sort property
* must be contained within this set. If {@code allowedSortProperties} is null,
* only basic sort property validation is performed.
*
* @param pageLink the PageLink object to validate.
* @param allowedSortProperties a Set of allowed sort property names, or null to skip
* this validation. If provided and the PageLink contains
* a sort order, the sort property must be in this set.
* @throws IncorrectParameterException if any of the following conditions are met:
* <ul>
* <li>{@code pageLink} is null</li>
* <li>page size is less than 1</li>
* <li>page number is negative</li>
* <li>sort property is malformed</li>
* <li>sort property is not in the {@code allowedSortProperties} set (when the set is provided and not null)</li>
* </ul>
*/
public static void validatePageLink(PageLink pageLink, Set<String> allowedSortProperties) {
if (pageLink == null) {
throw new IncorrectParameterException("Page link must be specified.");
} else if (pageLink.getPageSize() < 1) {
throw new IncorrectParameterException("Incorrect page link page size '"+pageLink.getPageSize()+"'. Page size must be greater than zero.");
throw new IncorrectParameterException("Incorrect page link page size '" + pageLink.getPageSize() + "'. Page size must be greater than zero.");
} else if (pageLink.getPage() < 0) {
throw new IncorrectParameterException("Incorrect page link page '"+pageLink.getPage()+"'. Page must be positive integer.");
throw new IncorrectParameterException("Incorrect page link page '" + pageLink.getPage() + "'. Page must be positive integer.");
} else if (pageLink.getSortOrder() != null) {
if (!isValidProperty(pageLink.getSortOrder().getProperty())) {
String sortProperty = pageLink.getSortOrder().getProperty();
if (!isValidProperty(sortProperty)) {
throw new IncorrectParameterException("Invalid page link sort property");
}
if (allowedSortProperties != null && !allowedSortProperties.contains(sortProperty)) {
throw new IncorrectParameterException(
"Unsupported sort property '" + sortProperty + "'. Only '" + String.join("', '", allowedSortProperties) + "' are allowed."
);
}
}
}

69
dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java

@ -0,0 +1,69 @@
/**
* Copyright © 2016-2025 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.dao.service.validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelDao;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService;
import java.util.Optional;
@Component
@RequiredArgsConstructor
class AiModelDataValidator extends DataValidator<AiModel> {
private final TenantService tenantService;
private final AiModelDao aiModelDao;
@Override
protected AiModel validateUpdate(TenantId tenantId, AiModel model) {
Optional<AiModel> existing = aiModelDao.findByTenantIdAndId(tenantId, model.getId());
if (existing.isEmpty()) {
throw new DataValidationException("Cannot update non-existent AI model!");
}
return existing.get();
}
@Override
protected void validateDataImpl(TenantId tenantId, AiModel model) {
// ID validation
if (model.getId() != null) {
if (model.getUuidId() == null) {
throw new DataValidationException("AI model UUID should be specified!");
}
if (model.getId().isNullUid()) {
throw new DataValidationException("AI model UUID must not be the reserved null value!");
}
}
// tenant ID validation
if (model.getTenantId() == null || model.getTenantId().getId() == null) {
throw new DataValidationException("AI model should be assigned to tenant!");
}
if (model.getTenantId().isSysTenantId()) {
throw new DataValidationException("AI model cannot be assigned to the system tenant!");
}
if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model reference a non-existent tenant!");
}
}
}

89
dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java

@ -0,0 +1,89 @@
/**
* Copyright © 2016-2025 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.dao.sql.ai;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.dao.ExportableEntityRepository;
import org.thingsboard.server.dao.model.sql.AiModelEntity;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
interface AiModelRepository extends JpaRepository<AiModelEntity, UUID>, ExportableEntityRepository<AiModelEntity> {
Optional<AiModelEntity> findByTenantIdAndId(UUID tenantId, UUID id);
Optional<AiModelEntity> findByTenantIdAndName(UUID tenantId, String name);
@Query(
value = """
SELECT *
FROM ai_model model
WHERE model.tenant_id = :tenantId
AND (:textSearch IS NULL
OR model.name ILIKE '%' || :textSearch || '%'
OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%'
OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%')
""",
countQuery = """
SELECT COUNT(*)
FROM ai_model model
WHERE model.tenant_id = :tenantId
AND (:textSearch IS NULL
OR model.name ILIKE '%' || :textSearch || '%'
OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%'
OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%')
""",
nativeQuery = true
)
Page<AiModelEntity> findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable);
@Query("SELECT ai_model.id FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId")
Page<UUID> findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable);
@Query("SELECT externalId FROM AiModelEntity WHERE id = :id")
Optional<UUID> getExternalIdById(@Param("id") UUID id);
long countByTenantId(UUID tenantId);
@Transactional
@Modifying
@Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.id IN (:ids)")
int deleteByIdIn(@Param("ids") Set<UUID> ids);
@Transactional
@Modifying
@Query(value = """
DELETE FROM ai_model
WHERE tenant_id = :tenantId
RETURNING id
""", nativeQuery = true
)
Set<UUID> deleteByTenantId(@Param("tenantId") UUID tenantId);
@Transactional
@Modifying
@Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId AND ai_model.id IN (:ids)")
int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set<UUID> ids);
}

139
dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java

@ -0,0 +1,139 @@
/**
* Copyright © 2016-2025 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.dao.sql.ai;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.ai.AiModelDao;
import org.thingsboard.server.dao.model.sql.AiModelEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@SqlDao
@Component
@RequiredArgsConstructor
class JpaAiModelDao extends JpaAbstractDao<AiModelEntity, AiModel> implements AiModelDao {
private final AiModelRepository aiModelRepository;
@Override
public Optional<AiModel> findByTenantIdAndId(TenantId tenantId, AiModelId modelId) {
return aiModelRepository.findByTenantIdAndId(tenantId.getId(), modelId.getId()).map(DaoUtil::getData);
}
@Override
public AiModel findByTenantIdAndName(UUID tenantId, String name) {
return DaoUtil.getData(aiModelRepository.findByTenantIdAndName(tenantId, name));
}
@Override
public AiModel findByTenantIdAndExternalId(UUID tenantId, UUID externalId) {
return DaoUtil.getData(aiModelRepository.findByTenantIdAndExternalId(tenantId, externalId));
}
@Override
public PageData<AiModel> findAllByTenantId(TenantId tenantId, PageLink pageLink) {
return findByTenantId(tenantId.getId(), pageLink);
}
@Override
public PageData<AiModel> findByTenantId(UUID tenantId, PageLink pageLink) {
return DaoUtil.toPageData(aiModelRepository.findByTenantId(
tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink))
);
}
@Override
public PageData<AiModelId> findIdsByTenantId(UUID tenantId, PageLink pageLink) {
return DaoUtil.pageToPageData(aiModelRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelId::new));
}
private static PageRequest toPageRequest(PageLink pageLink) {
Sort sort;
SortOrder sortOrder = pageLink.getSortOrder();
if (sortOrder == null) {
sort = Sort.by(Sort.Direction.ASC, "id");
} else {
sort = JpaSort.unsafe(
Sort.Direction.fromString(sortOrder.getDirection().name()),
AiModelEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty())
).and(Sort.by(Sort.Direction.ASC, "id"));
}
return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort);
}
@Override
public AiModelId getExternalIdByInternal(AiModelId internalId) {
return aiModelRepository.getExternalIdById(internalId.getId()).map(AiModelId::new).orElse(null);
}
@Override
public Long countByTenantId(TenantId tenantId) {
return aiModelRepository.countByTenantId(tenantId.getId());
}
@Override
public boolean deleteById(TenantId tenantId, AiModelId modelId) {
return aiModelRepository.deleteByIdIn(Set.of(modelId.getId())) > 0;
}
@Override
public Set<AiModelId> deleteByTenantId(TenantId tenantId) {
return aiModelRepository.deleteByTenantId(tenantId.getId()).stream()
.map(AiModelId::new)
.collect(toSet());
}
@Override
public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) {
return aiModelRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(modelId.getId())) > 0;
}
@Override
public EntityType getEntityType() {
return EntityType.AI_MODEL;
}
@Override
protected Class<AiModelEntity> getEntityClass() {
return AiModelEntity.class;
}
@Override
protected JpaRepository<AiModelEntity, UUID> getRepository() {
return aiModelRepository;
}
}

3
dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java

@ -178,7 +178,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.RPC, EntityType.QUEUE,
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_TARGET, EntityType.QUEUE_STATS, EntityType.CUSTOMER,
EntityType.DOMAIN, EntityType.MOBILE_APP_BUNDLE, EntityType.MOBILE_APP, EntityType.OAUTH2_CLIENT
EntityType.DOMAIN, EntityType.MOBILE_APP_BUNDLE, EntityType.MOBILE_APP, EntityType.OAUTH2_CLIENT,
EntityType.AI_MODEL
);
}

2
dao/src/main/resources/sql/schema-entities-idx.sql

@ -115,3 +115,5 @@ CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(res
CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id);
CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id);

12
dao/src/main/resources/sql/schema-entities.sql

@ -963,3 +963,15 @@ CREATE TABLE IF NOT EXISTS job (
configuration varchar NOT NULL,
result varchar
);
CREATE TABLE IF NOT EXISTS ai_model (
id UUID NOT NULL PRIMARY KEY,
external_id UUID,
created_time BIGINT NOT NULL,
tenant_id UUID NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
configuration JSONB NOT NULL,
CONSTRAINT ai_model_name_unq_key UNIQUE (tenant_id, name),
CONSTRAINT ai_model_external_id_unq_key UNIQUE (tenant_id, external_id)
);

5
dao/src/test/resources/application-test.properties

@ -111,6 +111,9 @@ cache.specs.mobileSecretKey.maxSize=10000
cache.specs.trendzSettings.timeToLiveInMinutes=1440
cache.specs.trendzSettings.maxSize=10000
cache.specs.aiModel.timeToLiveInMinutes=1440
cache.specs.aiModel.maxSize=10000
redis.connection.host=localhost
redis.connection.port=6379
redis.connection.db=0
@ -158,4 +161,4 @@ queue.core.poll-interval=5
queue.core.partitions=2
queue.rule-engine.poll-interval=5
spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect
spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect

112
pom.xml

@ -93,6 +93,8 @@
<netty.version>4.1.119.Final</netty.version>
<netty-tcnative.version>2.0.65.Final</netty-tcnative.version>
<reactor-netty.version>1.1.18</reactor-netty.version>
<reactive-streams.version>1.0.4</reactive-streams.version>
<reactor-core.version>3.6.12</reactor-core.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version>
<rabbitmq.version>5.21.0</rabbitmq.version>
<surefire.version>3.2.5</surefire.version>
@ -119,7 +121,14 @@
<snakeyaml.version>2.2</snakeyaml.version>
<aws.sdk.version>1.12.701</aws.sdk.version>
<pubsub.client.version>1.128.1</pubsub.client.version>
<google.common.protos.version>2.37.1</google.common.protos.version>
<google-auth-library.version>1.34.0</google-auth-library.version>
<google-http-client.version>1.46.3</google-http-client.version>
<google-api-common.version>2.48.0</google-api-common.version>
<google-api-gax.version>2.65.0</google-api-gax.version>
<google-proto-common.version>2.37.1</google-proto-common.version>
<google-proto-iam-v1.version>1.51.0</google-proto-iam-v1.version>
<passay.version>1.6.4</passay.version>
<ua-parser.version>1.6.1</ua-parser.version>
<commons-beanutils.version>1.9.4</commons-beanutils.version>
@ -135,6 +144,13 @@
<antisamy.version>1.7.5</antisamy.version>
<snmp4j.version>3.8.0</snmp4j.version>
<json-path.version>2.9.0</json-path.version>
<langchain4j.version>1.1.0</langchain4j.version>
<error_prone_annotations.version>2.38.0</error_prone_annotations.version>
<animal-sniffer-annotations.version>1.24</animal-sniffer-annotations.version>
<auto-value-annotations.version>1.11.0</auto-value-annotations.version>
<checker-qual.version>3.49.3</checker-qual.version>
<perfmark-api.version>0.27.0</perfmark-api.version>
<threetenbp.version>1.7.0</threetenbp.version>
<!-- TEST SCOPE -->
<awaitility.version>4.2.1</awaitility.version>
<dbunit.version>2.7.3</dbunit.version>
@ -1260,6 +1276,16 @@
<artifactId>reactor-netty-http</artifactId>
<version>${reactor-netty.version}</version>
</dependency>
<dependency>
<groupId>org.reactivestreams</groupId>
<artifactId>reactive-streams</artifactId>
<version>${reactive-streams.version}</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>${reactor-core.version}</version>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
@ -2079,10 +2105,55 @@
<artifactId>google-cloud-pubsub</artifactId>
<version>${pubsub.client.version}</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-credentials</artifactId>
<version>${google-auth-library.version}</version>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>${google-auth-library.version}</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client</artifactId>
<version>${google-http-client.version}</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client-gson</artifactId>
<version>${google-http-client.version}</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>api-common</artifactId>
<version>${google-api-common.version}</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax</artifactId>
<version>${google-api-gax.version}</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax-grpc</artifactId>
<version>${google-api-gax.version}</version>
</dependency>
<dependency>
<groupId>com.google.api</groupId>
<artifactId>gax-httpjson</artifactId>
<version>${google-api-gax.version}</version>
</dependency>
<dependency>
<groupId>com.google.api.grpc</groupId>
<artifactId>proto-google-common-protos</artifactId>
<version>${google.common.protos.version}</version>
<version>${google-proto-common.version}</version>
</dependency>
<dependency>
<groupId>com.google.api.grpc</groupId>
<artifactId>proto-google-iam-v1</artifactId>
<version>${google-proto-iam-v1.version}</version>
</dependency>
<dependency>
<groupId>org.passay</groupId>
@ -2336,6 +2407,43 @@
<artifactId>rocksdbjni</artifactId>
<version>${rocksdbjni.version}</version>
</dependency>
<dependency>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
<version>${error_prone_annotations.version}</version>
</dependency>
<dependency>
<groupId>org.codehaus.mojo</groupId>
<artifactId>animal-sniffer-annotations</artifactId>
<version>${animal-sniffer-annotations.version}</version>
</dependency>
<dependency>
<groupId>com.google.auto.value</groupId>
<artifactId>auto-value-annotations</artifactId>
<version>${auto-value-annotations.version}</version>
</dependency>
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>${checker-qual.version}</version>
</dependency>
<dependency>
<groupId>io.perfmark</groupId>
<artifactId>perfmark-api</artifactId>
<version>${perfmark-api.version}</version>
</dependency>
<dependency>
<groupId>org.threeten</groupId>
<artifactId>threetenbp</artifactId>
<version>${threetenbp.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

4
rule-engine/rule-engine-api/pom.xml

@ -98,6 +98,10 @@
<artifactId>jakarta.mail</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

27
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2025 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.rule.engine.api;
import com.google.common.util.concurrent.FluentFuture;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
public interface RuleEngineAiChatModelService {
<C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest);
}

5
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState;
import org.thingsboard.server.common.data.script.ScriptLanguage;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -422,6 +423,10 @@ public interface TbContext {
AuditLogService getAuditLogService();
RuleEngineAiChatModelService getAiChatModelService();
AiModelService getAiModelService();
// Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node
MqttClientSettings getMqttClientSettings();

43
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java

@ -16,11 +16,11 @@
package org.thingsboard.rule.engine.api.util;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.CollectionUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
@ -29,15 +29,18 @@ import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Created by ashvayka on 19.01.18.
*/
public class TbNodeUtils {
public final class TbNodeUtils {
private TbNodeUtils() {
throw new IllegalStateException("Utility class");
}
private static final Pattern DATA_PATTERN = Pattern.compile("(\\$\\[)(.*?)(])");
private static final String ALL_DATA_TEMPLATE = "$[*]";
private static final String ALL_METADATA_TEMPLATE = "${*}";
public static <T> T convert(TbNodeConfiguration configuration, Class<T> clazz) throws TbNodeException {
try {
return JacksonUtil.treeToValue(configuration.getData(), clazz);
@ -47,16 +50,19 @@ public class TbNodeUtils {
}
public static List<String> processPatterns(List<String> patterns, TbMsg tbMsg) {
if (!CollectionUtils.isEmpty(patterns)) {
return patterns.stream().map(p -> processPattern(p, tbMsg)).collect(Collectors.toList());
if (CollectionsUtil.isEmpty(patterns)) {
return Collections.emptyList();
}
return Collections.emptyList();
return patterns.stream().map(p -> processPattern(p, tbMsg)).toList();
}
public static String processPattern(String pattern, TbMsg tbMsg) {
try {
String result = processPattern(pattern, tbMsg.getMetaData());
JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData());
result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json));
if (json.isObject()) {
Matcher matcher = DATA_PATTERN.matcher(result);
while (matcher.find()) {
@ -64,7 +70,7 @@ public class TbNodeUtils {
String[] keys = group.split("\\.");
JsonNode jsonNode = json;
for (String key : keys) {
if (!StringUtils.isEmpty(key) && jsonNode != null) {
if (StringUtils.isNotEmpty(key) && jsonNode != null) {
jsonNode = jsonNode.get(key);
} else {
jsonNode = null;
@ -83,15 +89,9 @@ public class TbNodeUtils {
}
}
@Deprecated(since = "3.6.1", forRemoval = true)
public static List<String> processPatterns(List<String> patterns, TbMsgMetaData metaData) {
if (!CollectionUtils.isEmpty(patterns)) {
return patterns.stream().map(p -> processPattern(p, metaData)).collect(Collectors.toList());
}
return Collections.emptyList();
}
public static String processPattern(String pattern, TbMsgMetaData metaData) {
private static String processPattern(String pattern, TbMsgMetaData metaData) {
String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData());
pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement);
return processTemplate(pattern, metaData.values());
}
@ -108,10 +108,11 @@ public class TbNodeUtils {
}
static String formatDataVarTemplate(String key) {
return "$[" + key + ']';
return "$[" + key + "]";
}
static String formatMetadataVarTemplate(String key) {
return "${" + key + '}';
return "${" + key + "}";
}
}

158
rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java

@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.Map;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
@ -167,4 +169,160 @@ public class TbNodeUtilsTest {
assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is("${null}"));
assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is(String.format(METADATA_VARIABLE_TEMPLATE, (String) null)));
}
@Test
public void testAllMetadataTemplateReplacement() {
// GIVEN
String pattern = "META ${*}";
var metadata = new TbMsgMetaData();
metadata.putValue("meta_key", "meta_value");
var msg = TbMsg.newMsg()
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(metadata)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "META {\"meta_key\":\"meta_value\"}";
assertThat(actual, is(expected));
}
@Test
public void testMultipleAllMetadataTemplatesReplacement() {
// GIVEN
String pattern = "${*} then again ${*}";
var metadata = new TbMsgMetaData();
metadata.putValue("meta_key", "meta_value");
var msg = TbMsg.newMsg()
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(metadata)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "{\"meta_key\":\"meta_value\"} then again {\"meta_key\":\"meta_value\"}";
assertThat(actual, is(expected));
}
@Test
public void testAllDataTemplateReplacement() {
// GIVEN
String pattern = "DATA $[*]";
var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value");
var msg = TbMsg.newMsg()
.data(JacksonUtil.toString(dataJson))
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "DATA {\"data_key\":\"data_value\"}";
assertThat(actual, is(expected));
}
@Test
public void testMultipleAllDataTemplatesReplacement() {
// GIVEN
String pattern = "$[*] then again $[*]";
var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value");
var msg = TbMsg.newMsg()
.data(JacksonUtil.toString(dataJson))
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "{\"data_key\":\"data_value\"} then again {\"data_key\":\"data_value\"}";
assertThat(actual, is(expected));
}
@Test
public void testAllDataAndAllMetadataTemplatesSimultaneously() {
// GIVEN
String pattern = "META ${*} DATA $[*]";
var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value"));
var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value");
var msg = TbMsg.newMsg()
.data(JacksonUtil.toString(dataJson))
.metaData(metadata)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "META {\"meta_key\":\"meta_value\"} DATA {\"data_key\":\"data_value\"}";
assertThat(actual, is(expected));
}
@Test
public void testAllDataAndAllMetadataTemplatesSimultaneouslyEmpty() {
// GIVEN
String pattern = "META ${*} DATA $[*]";
var msg = TbMsg.newMsg()
.data(TbMsg.EMPTY_JSON_OBJECT)
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "META {} DATA {}";
assertThat(actual, is(expected));
}
@Test
public void testAllDataTemplateArray() {
// GIVEN
String pattern = "DATA $[*]";
var msg = TbMsg.newMsg()
.data("[1, \"two\", true]")
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "DATA [1,\"two\",true]";
assertThat(actual, is(expected));
}
@Test
public void testMixedAllDataMetadataAndNormalTemplates() {
// GIVEN
String pattern = "fullMeta=${*}, singleMeta=${meta_key}, fullData=$[*], singleData=$[data_key]";
var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value"));
var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value");
var msg = TbMsg.newMsg()
.data(JacksonUtil.toString(dataJson))
.metaData(metadata)
.build();
// WHEN
String actual = TbNodeUtils.processPattern(pattern, msg);
// THEN
String expected = "fullMeta={\"meta_key\":\"meta_value\"}, singleMeta=meta_value, fullData={\"data_key\":\"data_value\"}, singleData=data_value";
assertThat(actual, is(expected));
}
}

4
rule-engine/rule-engine-components/pom.xml

@ -153,6 +153,10 @@
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
</dependencies>
<build>

136
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java

@ -0,0 +1,136 @@
/**
* Copyright © 2016-2025 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.rule.engine.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.chat.request.json.JsonArraySchema;
import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
import dev.langchain4j.model.chat.request.json.JsonEnumSchema;
import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
import dev.langchain4j.model.chat.request.json.JsonNullSchema;
import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
import dev.langchain4j.model.chat.request.json.JsonSchema;
import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
import dev.langchain4j.model.chat.request.json.JsonStringSchema;
import java.util.ArrayList;
import java.util.List;
/**
* Converts a Jackson {@link ObjectNode} JSON Schema into a Langchain4j {@link JsonSchema} model.
*/
final class Langchain4jJsonSchemaAdapter {
private Langchain4jJsonSchemaAdapter() {
throw new AssertionError("Can't instantiate utility class");
}
/**
* Creates a Langchain4j {@link JsonSchema} from the given root JSON Schema node.
*
* @param rootSchemaNode a valid JSON Schema as a Jackson {@link ObjectNode}
* @return the corresponding Langchain4j {@link JsonSchema}
*/
public static JsonSchema fromObjectNode(ObjectNode rootSchemaNode) {
return JsonSchema.builder()
.name(rootSchemaNode.get("title").textValue())
.rootElement(parse(rootSchemaNode))
.build();
}
private static JsonSchemaElement parse(JsonNode schemaNode) {
String description = schemaNode.hasNonNull("description") ? schemaNode.get("description").textValue() : null;
if (schemaNode.has("enum")) { // enum schemas can be defined without 'type'
return parseEnum(schemaNode).description(description).build();
}
String type = schemaNode.get("type").textValue();
return switch (type) {
case "string" -> JsonStringSchema.builder().description(description).build();
case "integer" -> JsonIntegerSchema.builder().description(description).build();
case "boolean" -> JsonBooleanSchema.builder().description(description).build();
case "number" -> JsonNumberSchema.builder().description(description).build();
case "null" -> new JsonNullSchema();
case "object" -> parseObject(schemaNode).description(description).build();
case "array" -> parseArray(schemaNode).description(description).build();
default -> throw new IllegalArgumentException("Unsupported JSON Schema type: " + type);
};
}
private static JsonEnumSchema.Builder parseEnum(JsonNode enumSchema) {
var builder = new JsonEnumSchema.Builder();
List<String> enumValues = new ArrayList<>();
for (JsonNode element : enumSchema.get("enum")) {
if (!element.isTextual()) {
throw new IllegalArgumentException("Expected each 'enum' element to be a string, but found: " + element.getNodeType());
}
enumValues.add(element.textValue());
}
builder.enumValues(enumValues);
return builder;
}
private static JsonObjectSchema.Builder parseObject(JsonNode objectSchema) {
var builder = new JsonObjectSchema.Builder();
JsonNode propertiesNode = objectSchema.get("properties");
if (propertiesNode != null) {
propertiesNode.fields().forEachRemaining(entry -> {
String key = entry.getKey();
JsonNode value = entry.getValue();
builder.addProperty(key, parse(value));
});
}
List<String> required = new ArrayList<>();
JsonNode requiredNode = objectSchema.get("required");
if (requiredNode != null) {
for (JsonNode value : requiredNode) {
required.add(value.textValue());
}
}
builder.required(required);
boolean additionalProperties = true; // default value if 'additionalProperties' is not set
JsonNode additionalPropertiesNode = objectSchema.get("additionalProperties");
if (additionalPropertiesNode != null) {
if (!additionalPropertiesNode.isBoolean()) {
throw new IllegalArgumentException("Expected 'additionalProperties' to be a boolean, but found: " + additionalPropertiesNode.getNodeType());
}
additionalProperties = additionalPropertiesNode.booleanValue();
}
builder.additionalProperties(additionalProperties);
return builder;
}
private static JsonArraySchema.Builder parseArray(JsonNode arraySchema) {
var builder = new JsonArraySchema.Builder();
if (arraySchema.hasNonNull("items")) {
builder.items(parse(arraySchema.get("items")));
}
return builder;
}
}

203
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java

@ -0,0 +1,203 @@
/**
* Copyright © 2016-2025 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.rule.engine.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.FutureCallback;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.response.ChatResponse;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.id.AiModelId;
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.dao.exception.DataValidationException;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
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;
@RuleNode(
type = ComponentType.EXTERNAL,
name = "AI request",
nodeDescription = "Sends a request to an AI model using system and user prompts. Supports JSON mode.",
nodeDetails = """
Interact with large language models (LLMs) by sending dynamic requests from your rule chain.
You can select a specific <strong>AI model</strong> and define its behavior using a <strong>system prompt</strong> (optional context or role) and a <strong>user prompt</strong> (the main task).
Both prompts can be populated with data and metadata from the incoming message using patterns.
For example, the <code>$[*]</code> and <code>${*}</code> patterns allow you to access the all message body and all metadata, respectively.
<br><br>
After sending the request, the node waits for a response within a configured <strong>timeout</strong>.
You can specify the desired <strong>response format</strong> as <strong>Text</strong>, <strong>JSON</strong>, or provide a specific <strong>JSON Schema</strong> to structure the output.
The AI-generated content is forwarded as the body of the outgoing message; the originator, message type, and metadata from the incoming message remain unchanged.
<br><br>
Output connections: <code>Success</code>, <code>Failure</code>.
""",
configClazz = TbAiNodeConfiguration.class,
configDirective = "tbExternalNodeAiConfig",
iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==",
ruleChainTypes = RuleChainType.CORE
)
public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
private String systemPrompt;
private String userPrompt;
private ResponseFormat responseFormat;
private int timeoutSeconds;
private AiModelId modelId;
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx);
var config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class);
String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: ";
try {
validateFields(config, errorPrefix);
} catch (DataValidationException e) {
throw new TbNodeException(e, true);
}
modelId = config.getModelId();
Optional<AiModel> modelOpt = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId);
if (modelOpt.isEmpty()) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true);
}
AiModel model = modelOpt.get();
AiModelType modelType = model.getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
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();
}
systemPrompt = config.getSystemPrompt();
userPrompt = config.getUserPrompt();
timeoutSeconds = config.getTimeoutSeconds();
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);
List<ChatMessage> chatMessages = new ArrayList<>(2);
if (systemPrompt != null) {
chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg)));
}
chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg)));
var chatRequest = ChatRequest.builder()
.messages(chatMessages)
.responseFormat(responseFormat)
.build();
sendChatRequestAsync(ctx, chatRequest).addCallback(new FutureCallback<>() {
@Override
public void onSuccess(ChatResponse chatResponse) {
String response = chatResponse.aiMessage().text();
if (!isValidJsonObject(response)) {
response = wrapInJsonObject(response);
}
tellSuccess(ctx, ackedMsg.transform()
.data(response)
.build());
}
@Override
public void onFailure(@NonNull Throwable t) {
tellFailure(ctx, ackedMsg, t);
}
}, directExecutor());
}
private <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(TbContext ctx, ChatRequest chatRequest) {
return ctx.getAiModelService().findAiModelByTenantIdAndIdAsync(ctx.getTenantId(), modelId).transformAsync(modelOpt -> {
if (modelOpt.isEmpty()) {
throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found");
}
AiModel model = modelOpt.get();
AiModelType modelType = model.getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType);
}
@SuppressWarnings("unchecked")
AiChatModelConfig<C> chatModelConfig = (AiChatModelConfig<C>) model.getConfiguration();
chatModelConfig = chatModelConfig
.withTimeoutSeconds(timeoutSeconds)
.withMaxRetries(0); // disable retries to respect timeout set in rule node config
return ctx.getAiChatModelService().sendChatRequestAsync(chatModelConfig, chatRequest);
}, ctx.getDbCallbackExecutor());
}
private static boolean isValidJsonObject(String jsonString) {
try {
JsonNode result = JacksonUtil.toJsonNode(jsonString);
return result != null && result.isObject();
} catch (IllegalArgumentException e) {
return false;
}
}
private static String wrapInJsonObject(String response) {
return JacksonUtil.newObjectNode().put("response", response).toString();
}
@Override
public void destroy() {
super.destroy();
systemPrompt = null;
userPrompt = null;
responseFormat = null;
modelId = null;
}
}

68
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java

@ -0,0 +1,68 @@
/**
* Copyright © 2016-2025 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.rule.engine.ai;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.validation.Length;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@Data
public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfiguration> {
@NotNull
private AiModelId modelId;
@Pattern(regexp = ".*\\S.*", message = "must not be blank")
@Length(min = 1, max = 10000)
private String systemPrompt;
@NotBlank
@Length(min = 1, max = 10000)
private String userPrompt;
@NotNull
@Valid
private TbResponseFormat responseFormat;
@Min(value = 1, message = "must be at least 1 second")
@Max(value = 600, message = "cannot exceed 600 seconds (10 minutes)")
private int timeoutSeconds;
private boolean forceAck;
@Override
public TbAiNodeConfiguration defaultConfiguration() {
var configuration = new TbAiNodeConfiguration();
configuration.setSystemPrompt(
"You are a helpful AI assistant. Your primary function is to process the user's request and respond with a valid JSON object. " +
"Do not include any text, explanations, or markdown formatting before or after the JSON output."
);
configuration.setResponseFormat(new TbJsonResponseFormat());
configuration.setTimeoutSeconds(60);
configuration.setForceAck(true);
return configuration;
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save