AI rule node: refactor model config data structure; rename from AI settings to AI model settings

This commit is contained in:
Dmytro Skarzhynets 2025-06-23 16:16:42 +03:00
parent 459cc6d27e
commit 1343c4af3b
No known key found for this signature in database
GPG Key ID: 2B51652F224037DF
62 changed files with 900 additions and 878 deletions

View File

@ -14,15 +14,12 @@
-- limitations under the License.
--
CREATE TABLE ai_settings (
CREATE TABLE ai_model_settings (
id UUID NOT NULL PRIMARY KEY,
created_time BIGINT NOT NULL,
tenant_id UUID NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
provider VARCHAR(255) NOT NULL,
provider_config JSONB NOT NULL,
model VARCHAR(255) NOT NULL,
model_config JSONB,
CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name)
configuration JSONB NOT NULL,
CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name)
);

View File

@ -35,7 +35,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
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.RuleEngineAiService;
import org.thingsboard.rule.engine.api.RuleEngineAiModelService;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
import org.thingsboard.rule.engine.api.sms.SmsSenderFactory;
@ -63,7 +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.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -314,11 +314,11 @@ public class ActorSystemContext {
@Autowired
@Getter
private RuleEngineAiService aiService;
private RuleEngineAiModelService aiModelService;
@Autowired
@Getter
private AiSettingsService aiSettingsService;
private AiModelSettingsService aiModelSettingsService;
@Autowired
@Getter

View File

@ -28,7 +28,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager;
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.RuleEngineAiService;
import org.thingsboard.rule.engine.api.RuleEngineAiModelService;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
@ -77,7 +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.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -1016,13 +1016,13 @@ public class DefaultTbContext implements TbContext {
}
@Override
public RuleEngineAiService getAiService() {
return mainCtx.getAiService();
public RuleEngineAiModelService getAiModelService() {
return mainCtx.getAiModelService();
}
@Override
public AiSettingsService getAiSettingsService() {
return mainCtx.getAiSettingsService();
public AiModelSettingsService getAiModelSettingsService() {
return mainCtx.getAiModelSettingsService();
}
@Override

View File

@ -26,9 +26,9 @@ 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.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.service.security.permission.Operation;
@ -47,70 +47,70 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERT
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
@RestController
@RequestMapping("/api/ai-settings")
public class AiSettingsController extends BaseController {
@RequestMapping("/api/ai-model-settings")
public class AiModelSettingsController extends BaseController {
private static final Set<String> ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name", "provider", "model");
private static final Set<String> ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name");
@ApiOperation(
value = "Create or update AI settings (saveAiSettings)",
notes = "Creates or updates an AI settings record.\n\n" +
value = "Create or update AI model settings (saveAiModelSettings)",
notes = "Creates or updates an AI model settings record.\n\n" +
"• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new settings 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 settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." +
"Tenant ID for the AI model settings 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 AiSettings saveAiSettings(@RequestBody AiSettings aiSettings) throws ThingsboardException {
public AiModelSettings saveAiModelSettings(@RequestBody AiModelSettings settings) throws ThingsboardException {
var user = getCurrentUser();
aiSettings.setTenantId(user.getTenantId());
checkEntity(aiSettings.getId(), aiSettings, Resource.AI_SETTINGS);
return tbAiSettingsService.save(aiSettings, user);
settings.setTenantId(user.getTenantId());
checkEntity(settings.getId(), settings, Resource.AI_MODEL_SETTINGS);
return tbAiModelSettingsService.save(settings, user);
}
@ApiOperation(
value = "Get AI settings by ID (getAiSettingsById)",
notes = "Fetches an AI settings record by its `id`." +
value = "Get AI model settings by ID (getAiModelSettingsById)",
notes = "Fetches an AI model settings record by its `id`." +
TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping("/{aiSettingsId}")
public AiSettings getAiSettingsById(
@GetMapping("/{aiModelSettingsId}")
public AiModelSettings getAiModelSettingsById(
@Parameter(
description = "ID of the AI settings record",
description = "ID of the AI model settings record",
required = true,
example = "de7900d4-30e2-11f0-9cd2-0242ac120002"
)
@PathVariable("aiSettingsId") UUID aiSettingsUuid
@PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid
) throws ThingsboardException {
return checkAiSettingsId(new AiSettingsId(aiSettingsUuid), Operation.READ);
return checkAiModelSettingsId(new AiModelSettingsId(aiModelSettingsUuid), Operation.READ);
}
@ApiOperation(
value = "Get AI settings (getAiSettings)",
notes = "Returns a page of AI settings. " +
value = "Get AI model settings (getAiModelSettings)",
notes = "Returns a page of AI model settings. " +
PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@GetMapping
public PageData<AiSettings> getAiSettings(
public PageData<AiModelSettings> getAiModelSettings(
@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true)
@RequestParam int pageSize,
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true)
@RequestParam int page,
@Parameter(description = AI_SETTINGS_TEXT_SEARCH_DESCRIPTION)
@RequestParam(required = false) String textSearch,
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "model"}))
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"}))
@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_SETTINGS, Operation.READ);
accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.READ);
validateSortProperty(sortProperty);
var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
return aiSettingsService.findAiSettingsByTenantId(user.getTenantId(), pageLink);
return aiModelSettingsService.findAiModelSettingsByTenantId(user.getTenantId(), pageLink);
}
private static void validateSortProperty(String sortProperty) {
@ -120,31 +120,31 @@ public class AiSettingsController extends BaseController {
}
@ApiOperation(
value = "Delete AI settings by ID (deleteAiSettingsById)",
notes = "Deletes the AI settings record by its `id`. " +
value = "Delete AI model settings by ID (deleteAiModelSettingsById)",
notes = "Deletes the AI model settings 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("/{aiSettingsId}")
public boolean deleteAiSettingsById(
@DeleteMapping("/{aiModelSettingsId}")
public boolean deleteAiModelSettingsById(
@Parameter(
description = "ID of the AI settings record",
description = "ID of the AI model settings record",
required = true,
example = "de7900d4-30e2-11f0-9cd2-0242ac120002"
)
@PathVariable("aiSettingsId") UUID aiSettingsUuid
@PathVariable("aiModelSettingsId") UUID aiModelSettingsUuid
) throws ThingsboardException {
var user = getCurrentUser();
var aiSettingsId = new AiSettingsId(aiSettingsUuid);
accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE);
Optional<AiSettings> aiSettingsOpt = aiSettingsService.findAiSettingsByTenantIdAndId(user.getTenantId(), aiSettingsId);
if (aiSettingsOpt.isEmpty()) {
var settingsId = new AiModelSettingsId(aiModelSettingsUuid);
accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE);
Optional<AiModelSettings> toDelete = aiModelSettingsService.findAiModelSettingsByTenantIdAndId(user.getTenantId(), settingsId);
if (toDelete.isEmpty()) {
return false;
}
accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE, aiSettingsId, aiSettingsOpt.get());
return tbAiSettingsService.delete(aiSettingsOpt.get(), user);
accessControlService.checkPermission(user, Resource.AI_MODEL_SETTINGS, Operation.DELETE, settingsId, toDelete.get());
return tbAiModelSettingsService.delete(toDelete.get(), user);
}
}

View File

@ -63,7 +63,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.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.alarm.AlarmInfo;
@ -78,7 +78,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.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.AlarmCommentId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.AssetId;
@ -131,7 +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.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -177,7 +177,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.TbAiSettingsService;
import org.thingsboard.server.service.entitiy.ai.TbAiModelSettingsService;
import org.thingsboard.server.service.entitiy.user.TbUserSettingsService;
import org.thingsboard.server.service.ota.OtaPackageStateService;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@ -378,10 +378,10 @@ public abstract class BaseController {
protected CalculatedFieldService calculatedFieldService;
@Autowired
protected AiSettingsService aiSettingsService;
protected AiModelSettingsService aiModelSettingsService;
@Autowired
protected TbAiSettingsService tbAiSettingsService;
protected TbAiModelSettingsService tbAiModelSettingsService;
@Value("${server.log_controller_error_stack_trace}")
@Getter
@ -691,8 +691,8 @@ public abstract class BaseController {
case CALCULATED_FIELD:
checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation);
return;
case AI_SETTINGS:
checkAiSettingsId(new AiSettingsId(entityId.getId()), operation);
case AI_MODEL_SETTINGS:
checkAiModelSettingsId(new AiModelSettingsId(entityId.getId()), operation);
return;
default:
checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation);
@ -894,8 +894,8 @@ public abstract class BaseController {
return checkEntityId(notificationTargetId, notificationTargetService::findNotificationTargetById, operation);
}
AiSettings checkAiSettingsId(AiSettingsId aiSettingsId, Operation operation) throws ThingsboardException {
return checkEntityId(aiSettingsId, (tenantId, id) -> aiSettingsService.findAiSettingsByTenantIdAndId(tenantId, id).orElse(null), operation);
AiModelSettings checkAiModelSettingsId(AiModelSettingsId settingsId, Operation operation) throws ThingsboardException {
return checkEntityId(settingsId, (tenantId, id) -> aiModelSettingsService.findAiModelSettingsByTenantIdAndId(tenantId, id).orElse(null), operation);
}
protected <I extends EntityId> I emptyId(EntityType entityType) {

View File

@ -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.service.ai;
import dev.langchain4j.model.chat.ChatModel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.RuleEngineAiModelService;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
@Service
@RequiredArgsConstructor
class AiModelServiceImpl implements RuleEngineAiModelService {
private final Langchain4jChatModelConfigurer chatModelConfigurer;
@Override
public <C extends AiChatModelConfig<C>> ChatModel configureChatModel(AiChatModel<C> chatModel) {
return chatModel.configure(chatModelConfigurer);
}
}

View File

@ -1,106 +0,0 @@
/**
* 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 dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.RuleEngineAiService;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.model.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.MistralAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiSettingsService;
import java.time.Duration;
import java.util.NoSuchElementException;
import java.util.Optional;
@Service
@RequiredArgsConstructor
class AiServiceImpl implements RuleEngineAiService {
private final AiSettingsService aiSettingsService;
@Override
public ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId) {
Optional<AiSettings> aiSettingsOpt = aiSettingsService.findAiSettingsById(tenantId, aiSettingsId);
if (aiSettingsOpt.isEmpty()) {
throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found");
}
var aiSettings = aiSettingsOpt.get();
return configureChatModel(aiSettings.getProviderConfig(), aiSettings.getModelConfig());
}
@Override
public ChatModel configureChatModel(AiProviderConfig providerConfig, AiModelConfig modelConfig) {
return switch (providerConfig.getProvider()) {
case OPENAI -> {
var modelBuilder = OpenAiChatModel.builder()
.apiKey(providerConfig.getApiKey())
.modelName(modelConfig.getModel());
if (modelConfig instanceof OpenAiChatModelConfig config) {
modelBuilder.temperature(config.getTemperature());
if (config.getTimeoutSeconds() != null) {
modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds()));
}
modelBuilder.maxRetries(config.getMaxRetries());
}
yield modelBuilder.build();
}
case MISTRAL_AI -> {
var modelBuilder = MistralAiChatModel.builder()
.apiKey(providerConfig.getApiKey())
.modelName(modelConfig.getModel());
if (modelConfig instanceof MistralAiChatModelConfig config) {
modelBuilder.temperature(config.getTemperature());
if (config.getTimeoutSeconds() != null) {
modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds()));
}
modelBuilder.maxRetries(config.getMaxRetries());
}
yield modelBuilder.build();
}
case GOOGLE_AI_GEMINI -> {
var modelBuilder = GoogleAiGeminiChatModel.builder()
.apiKey(providerConfig.getApiKey())
.modelName(modelConfig.getModel());
if (modelConfig instanceof GoogleAiGeminiChatModelConfig config) {
modelBuilder.temperature(config.getTemperature());
if (config.getTimeoutSeconds() != null) {
modelBuilder.timeout(Duration.ofSeconds(config.getTimeoutSeconds()));
}
modelBuilder.maxRetries(config.getMaxRetries());
}
yield modelBuilder.build();
}
};
}
}

View File

@ -0,0 +1,70 @@
/**
* 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 dev.langchain4j.model.chat.ChatModel;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel;
import java.time.Duration;
@Component
public class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer {
@Override
public ChatModel configureChatModel(OpenAiChatModel chatModel) {
OpenAiChatModel.Config modelConfig = chatModel.modelConfig();
return dev.langchain4j.model.openai.OpenAiChatModel.builder()
.apiKey(chatModel.providerConfig().apiKey())
.modelName(chatModel.modelId())
.temperature(modelConfig.temperature())
.timeout(toDuration(modelConfig.timeoutSeconds()))
.maxRetries(modelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel) {
GoogleAiGeminiChatModel.Config modelConfig = chatModel.modelConfig();
return dev.langchain4j.model.googleai.GoogleAiGeminiChatModel.builder()
.apiKey(chatModel.providerConfig().apiKey())
.modelName(chatModel.modelId())
.temperature(modelConfig.temperature())
.timeout(toDuration(modelConfig.timeoutSeconds()))
.maxRetries(modelConfig.maxRetries())
.build();
}
@Override
public ChatModel configureChatModel(MistralAiChatModel chatModel) {
MistralAiChatModel.Config modelConfig = chatModel.modelConfig();
return dev.langchain4j.model.mistralai.MistralAiChatModel.builder()
.apiKey(chatModel.providerConfig().apiKey())
.modelName(chatModel.modelId())
.temperature(modelConfig.temperature())
.timeout(toDuration(modelConfig.timeoutSeconds()))
.maxRetries(modelConfig.maxRetries())
.build();
}
private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
}
}

View File

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

View File

@ -68,7 +68,7 @@ public class RelatedEdgesSourcingListener {
@TransactionalEventListener(
fallbackExecution = true,
condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_SETTINGS"
condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL_SETTINGS"
)
public void handleEvent(DeleteEntityEvent<?> event) {
executorService.submit(() -> {

View File

@ -96,7 +96,7 @@ public class EntityStateSourcingListener {
case ASSET -> {
onAssetUpdate(event.getEntity(), event.getOldEntity());
}
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_SETTINGS -> {
case ASSET_PROFILE, ENTITY_VIEW, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, lifecycleEvent);
}
case RULE_CHAIN -> {
@ -158,7 +158,7 @@ public class EntityStateSourcingListener {
Asset asset = (Asset) event.getEntity();
tbClusterService.onAssetDeleted(tenantId, asset, null);
}
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_SETTINGS -> {
case ASSET_PROFILE, ENTITY_VIEW, CUSTOMER, EDGE, NOTIFICATION_RULE, AI_MODEL_SETTINGS -> {
tbClusterService.broadcastEntityStateChangeEvent(tenantId, entityId, ComponentLifecycleEvent.DELETED);
}
case NOTIFICATION_REQUEST -> {

View File

@ -19,9 +19,9 @@ 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.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.dao.ai.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.entitiy.AbstractTbEntityService;
@ -30,22 +30,22 @@ import static java.util.Objects.requireNonNullElseGet;
@Service
@TbCoreComponent
@RequiredArgsConstructor
class DefaultTbAiSettingsService extends AbstractTbEntityService implements TbAiSettingsService {
class DefaultTbAiModelSettingsService extends AbstractTbEntityService implements TbAiModelSettingsService {
private final AiSettingsService aiSettingsService;
private final AiModelSettingsService aiModelSettingsService;
@Override
public AiSettings save(AiSettings aiSettings, User user) {
var actionType = aiSettings.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
public AiModelSettings save(AiModelSettings settings, User user) {
var actionType = settings.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
var tenantId = user.getTenantId();
aiSettings.setTenantId(tenantId);
settings.setTenantId(tenantId);
AiSettings savedSettings;
AiModelSettings savedSettings;
try {
savedSettings = aiSettingsService.save(aiSettings);
savedSettings = aiModelSettingsService.save(settings);
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(aiSettings.getId(), () -> emptyId(EntityType.AI_SETTINGS)), aiSettings, actionType, user, e);
logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(settings.getId(), () -> emptyId(EntityType.AI_MODEL_SETTINGS)), settings, actionType, user, e);
throw e;
}
@ -55,22 +55,22 @@ class DefaultTbAiSettingsService extends AbstractTbEntityService implements TbAi
}
@Override
public boolean delete(AiSettings aiSettings, User user) {
public boolean delete(AiModelSettings settings, User user) {
var actionType = ActionType.DELETED;
var tenantId = user.getTenantId();
var aiSettingsId = aiSettings.getId();
var settingsId = settings.getId();
boolean deleted;
try {
deleted = aiSettingsService.deleteByTenantIdAndId(tenantId, aiSettingsId);
deleted = aiModelSettingsService.deleteByTenantIdAndId(tenantId, settingsId);
} catch (Exception e) {
logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, e, aiSettingsId.toString());
logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, e, settingsId.toString());
throw e;
}
if (deleted) {
logEntityActionService.logEntityAction(tenantId, aiSettingsId, aiSettings, actionType, user, aiSettingsId.toString());
logEntityActionService.logEntityAction(tenantId, settingsId, settings, actionType, user, settingsId.toString());
}
return deleted;

View File

@ -16,12 +16,12 @@
package org.thingsboard.server.service.entitiy.ai;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
public interface TbAiSettingsService {
public interface TbAiModelSettingsService {
AiSettings save(AiSettings aiSettings, User user);
AiModelSettings save(AiModelSettings settings, User user);
boolean delete(AiSettings aiSettings, User user);
boolean delete(AiModelSettings settings, User user);
}

View File

@ -590,7 +590,7 @@ public class DefaultTbClusterService implements TbClusterService {
EntityType.ENTITY_VIEW,
EntityType.NOTIFICATION_RULE,
EntityType.CALCULATED_FIELD,
EntityType.AI_SETTINGS,
EntityType.AI_MODEL_SETTINGS,
EntityType.TENANT_PROFILE,
EntityType.DEVICE_PROFILE,
EntityType.ASSET_PROFILE)

View File

@ -52,7 +52,7 @@ public enum Resource {
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS,
CALCULATED_FIELD(EntityType.CALCULATED_FIELD),
AI_SETTINGS(EntityType.AI_SETTINGS);
AI_MODEL_SETTINGS(EntityType.AI_MODEL_SETTINGS);
private final Set<EntityType> entityTypes;

View File

@ -18,8 +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.AiSettings;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
@ -58,7 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.MOBILE_APP, tenantEntityPermissionChecker);
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker);
put(Resource.AI_SETTINGS, aiSettingsPermissionChecker);
put(Resource.AI_MODEL_SETTINGS, aiModelSettingsPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {
@ -149,7 +149,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
};
private static final PermissionChecker<AiSettingsId, AiSettings> aiSettingsPermissionChecker = new PermissionChecker<>() {
private static final PermissionChecker<AiModelSettingsId, AiModelSettings> aiModelSettingsPermissionChecker = new PermissionChecker<>() {
@Override
public boolean hasPermission(SecurityUser user, Operation operation) {
@ -157,7 +157,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
}
@Override
public boolean hasPermission(SecurityUser user, Operation operation, AiSettingsId entityId, AiSettings entity) {
public boolean hasPermission(SecurityUser user, Operation operation, AiModelSettingsId entityId, AiModelSettings entity) {
return user.getTenantId().equals(entity.getTenantId());
}

View File

@ -657,9 +657,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
aiSettings:
timeToLiveInMinutes: "${CACHE_SPECS_AI_SETTINGS_TTL:1440}" # AI settings cache TTL
maxSize: "${CACHE_SPECS_AI_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled
aiModelSettings:
timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_SETTINGS_TTL:1440}" # AI model settings cache TTL
maxSize: "${CACHE_SPECS_AI_MODEL_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled
# Deliberately placed outside the 'specs' group above
notificationRules:
@ -875,7 +875,7 @@ audit-log:
"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_settings": "${AUDIT_LOG_MASK_AI_SETTINGS:W}" # AI settings logging levels.
"ai_model_settings": "${AUDIT_LOG_MASK_AI_MODEL_SETTINGS:W}" # AI model settings logging levels.
sink:
# Type of external sink. possible options: none, elasticsearch
type: "${AUDIT_LOG_SINK_TYPE:none}"

View File

@ -16,8 +16,8 @@
package org.thingsboard.server.dao.ai;
import com.google.common.util.concurrent.FluentFuture;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
@ -25,18 +25,18 @@ import org.thingsboard.server.dao.entity.EntityDaoService;
import java.util.Optional;
public interface AiSettingsService extends EntityDaoService {
public interface AiModelSettingsService extends EntityDaoService {
AiSettings save(AiSettings aiSettings);
AiModelSettings save(AiModelSettings settings);
Optional<AiSettings> findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId);
Optional<AiModelSettings> findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId);
PageData<AiSettings> findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink);
PageData<AiModelSettings> findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink);
Optional<AiSettings> findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId);
Optional<AiModelSettings> findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId);
FluentFuture<Optional<AiSettings>> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId);
FluentFuture<Optional<AiModelSettings>> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId);
}

View File

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

View File

@ -37,7 +37,7 @@ public final 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_SETTINGS_CACHE = "aiSettings";
public static final String AI_MODEL_SETTINGS_CACHE = "aiModelSettings";
public static final String ASSET_PROFILE_CACHE = "assetProfiles";
public static final String ATTRIBUTES_CACHE = "attributes";

View File

@ -64,10 +64,10 @@ public enum EntityType {
MOBILE_APP_BUNDLE(38),
CALCULATED_FIELD(39),
CALCULATED_FIELD_LINK(40),
AI_SETTINGS(41, "ai_settings") {
AI_MODEL_SETTINGS(41, "ai_model_settings") {
@Override
public String getNormalName() {
return "AI settings";
return "AI model settings";
}
};

View File

@ -24,10 +24,8 @@ import org.thingsboard.server.common.data.BaseData;
import org.thingsboard.server.common.data.HasName;
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.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.model.AiModel;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import java.io.Serial;
@ -36,7 +34,7 @@ import java.io.Serial;
@Builder
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public final class AiSettings extends BaseData<AiSettingsId> implements HasTenantId, HasVersion, HasName {
public final class AiModelSettings extends BaseData<AiModelSettingsId> implements HasTenantId, HasVersion, HasName {
@Serial
private static final long serialVersionUID = 9017108678716011604L;
@ -44,7 +42,7 @@ public final class AiSettings extends BaseData<AiSettingsId> implements HasTenan
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "JSON object representing the ID of the tenant associated with these AI settings",
description = "JSON object representing the ID of the tenant associated with these AI model settings",
example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d"
)
TenantId tenantId;
@ -52,7 +50,7 @@ public final class AiSettings extends BaseData<AiSettingsId> implements HasTenan
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_ONLY,
description = "Version of the AI settings; increments automatically whenever the settings are changed",
description = "Version of the AI model settings; increments automatically whenever the settings are changed",
example = "7",
defaultValue = "1"
)
@ -61,49 +59,21 @@ public final class AiSettings extends BaseData<AiSettingsId> implements HasTenan
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Human-readable name of the AI settings; must be unique within the scope of the tenant",
description = "Human-readable name of the AI model settings; must be unique within the scope of the tenant",
example = "Default AI Settings"
)
String name;
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Name of the AI provider",
example = "OPENAI",
allowableValues = {"OPENAI", "GOOGLE_AI_GEMINI", "MISTRAL_AI"},
type = "string"
)
AiProvider provider;
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Configuration specific to the AI provider"
)
AiProviderConfig providerConfig;
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Identifier of the AI model",
example = "gpt-4o-mini"
)
String model;
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = """
Optional configuration specific to the AI model.
If provided, it must be one of the known `AiModelConfig` subtypes and any settings
you specify will override the models defaults; if omitted, the model will run with its built-in defaults."""
description = "Configuration of the AI model"
)
AiModelConfig modelConfig;
AiModel<?> configuration;
public AiSettings() {}
public AiModelSettings() {}
public AiSettings(AiSettingsId id) {
public AiModelSettings(AiModelSettingsId id) {
super(id);
}

View File

@ -0,0 +1,48 @@
/**
* 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.GoogleAiGeminiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModel;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "modelId",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OpenAiChatModel.class, name = "gpt-4o"),
@JsonSubTypes.Type(value = GoogleAiGeminiChatModel.class, name = "gemini-2.5-flash"),
@JsonSubTypes.Type(value = MistralAiChatModel.class, name = "mistral-medium-latest")
})
public interface AiModel<C extends AiModelConfig<C>> {
AiProviderConfig providerConfig();
AiModelType modelType();
String modelId();
C modelConfig();
AiModel<C> withModelConfig(C config);
}

View File

@ -15,42 +15,4 @@
*/
package org.thingsboard.server.common.data.ai.model;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "model",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o"),
@JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "gpt-4o-mini"),
@JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "gemini-2.0-flash"),
@JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "mistral-medium-latest")
})
public abstract class AiModelConfig {
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Identifier of the AI model"
)
private String model;
public abstract Integer getTimeoutSeconds();
public abstract void setTimeoutSeconds(Integer timeoutSeconds);
public abstract Integer getMaxRetries();
public abstract void setMaxRetries(Integer timeoutSeconds);
}
public interface AiModelConfig<C extends AiModelConfig<C>> {}

View File

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

View File

@ -1,62 +0,0 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@Schema(
name = "GoogleAiGeminiChatModelConfig",
description = "Configuration for Google AI Gemini chat models"
)
public final class GoogleAiGeminiChatModelConfig extends AiModelConfig {
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Identifier of the AI model",
allowableValues = "gemini-2.0-flash",
example = "gemini-2.0-flash"
)
public String getModel() {
return super.getModel();
}
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)",
example = "0.7"
)
private Double temperature;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Timeout (in seconds) for establishing HTTP connection"
)
private Integer timeoutSeconds;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)"
)
private Integer maxRetries;
}

View File

@ -1,62 +0,0 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@Schema(
name = "MistralAiChatModelConfig",
description = "Configuration for Mistral AI chat models"
)
public final class MistralAiChatModelConfig extends AiModelConfig {
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Identifier of the AI model",
allowableValues = "mistral-medium-latest",
example = "mistral-medium-latest"
)
public String getModel() {
return super.getModel();
}
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)",
example = "0.7"
)
private Double temperature;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Timeout (in seconds) for the entire HTTP call: applied to connect, read, and write operations"
)
private Integer timeoutSeconds;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)"
)
private Integer maxRetries;
}

View File

@ -1,62 +0,0 @@
/**
* 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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@Schema(
name = "OpenAiChatModelConfig",
description = "Configuration for OpenAI chat models"
)
public final class OpenAiChatModelConfig extends AiModelConfig {
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Identifier of the AI model",
allowableValues = {"gpt-4o", "gpt-4o-mini"},
example = "gpt-4o"
)
public String getModel() {
return super.getModel();
}
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Sampling temperature to control randomness: 0.0 (most deterministic) to 1.0 (most creative)",
example = "0.7"
)
private Double temperature;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Timeout (in seconds) for both establishing HTTP connection and receiving a response"
)
private Integer timeoutSeconds;
@Schema(
accessMode = Schema.AccessMode.READ_WRITE,
description = "Maximum number of times to retry an LLM call upon exception (except for non-retriable ones like authentication or invalid request errors)"
)
private Integer maxRetries;
}

View File

@ -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;
import org.thingsboard.server.common.data.ai.model.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelType;
public sealed interface AiChatModel<C extends AiChatModelConfig<C>> extends AiModel<C>
permits OpenAiChatModel, GoogleAiGeminiChatModel, MistralAiChatModel {
ChatModel configure(Langchain4jChatModelConfigurer configurer);
@Override
default AiModelType modelType() {
return AiModelType.CHAT;
}
@Override
C modelConfig();
@Override
AiChatModel<C> withModelConfig(C config);
}

View File

@ -0,0 +1,35 @@
/**
* 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 org.thingsboard.server.common.data.ai.model.AiModelConfig;
public sealed interface AiChatModelConfig<C extends AiChatModelConfig<C>> extends AiModelConfig<C>
permits OpenAiChatModel.Config, GoogleAiGeminiChatModel.Config, MistralAiChatModel.Config {
Double temperature();
Integer timeoutSeconds();
Integer maxRetries();
C withTemperature(Double temperature);
C withTimeoutSeconds(Integer timeoutSeconds);
C withMaxRetries(Integer maxRetries);
}

View File

@ -0,0 +1,60 @@
/**
* 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 org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
public record GoogleAiGeminiChatModel(
GoogleAiGeminiProviderConfig providerConfig,
String modelId,
Config modelConfig
) implements AiChatModel<GoogleAiGeminiChatModel.Config> {
public record Config(
Double temperature,
Integer timeoutSeconds,
Integer maxRetries
) implements AiChatModelConfig<GoogleAiGeminiChatModel.Config> {
@Override
public Config withTemperature(Double temperature) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public Config withTimeoutSeconds(Integer timeoutSeconds) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public Config withMaxRetries(Integer maxRetries) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public GoogleAiGeminiChatModel withModelConfig(GoogleAiGeminiChatModel.Config config) {
return new GoogleAiGeminiChatModel(providerConfig, modelId, config);
}
}

View File

@ -0,0 +1,28 @@
/**
* 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(OpenAiChatModel chatModel);
ChatModel configureChatModel(GoogleAiGeminiChatModel chatModel);
ChatModel configureChatModel(MistralAiChatModel chatModel);
}

View File

@ -0,0 +1,60 @@
/**
* 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 org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
public record MistralAiChatModel(
MistralAiProviderConfig providerConfig,
String modelId,
Config modelConfig
) implements AiChatModel<MistralAiChatModel.Config> {
public record Config(
Double temperature,
Integer timeoutSeconds,
Integer maxRetries
) implements AiChatModelConfig<MistralAiChatModel.Config> {
@Override
public Config withTemperature(Double temperature) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public Config withTimeoutSeconds(Integer timeoutSeconds) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public Config withMaxRetries(Integer maxRetries) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public MistralAiChatModel withModelConfig(Config config) {
return new MistralAiChatModel(providerConfig, modelId, config);
}
}

View File

@ -0,0 +1,60 @@
/**
* 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 org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
public record OpenAiChatModel(
OpenAiProviderConfig providerConfig,
String modelId,
Config modelConfig
) implements AiChatModel<OpenAiChatModel.Config> {
public record Config(
Double temperature,
Integer timeoutSeconds,
Integer maxRetries
) implements AiChatModelConfig<OpenAiChatModel.Config> {
@Override
public OpenAiChatModel.Config withTemperature(Double temperature) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public OpenAiChatModel.Config withTimeoutSeconds(Integer timeoutSeconds) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
@Override
public OpenAiChatModel.Config withMaxRetries(Integer maxRetries) {
return new Config(temperature, timeoutSeconds, maxRetries);
}
}
@Override
public ChatModel configure(Langchain4jChatModelConfigurer configurer) {
return configurer.configureChatModel(this);
}
@Override
public OpenAiChatModel withModelConfig(OpenAiChatModel.Config config) {
return new OpenAiChatModel(providerConfig, modelId, config);
}
}

View File

@ -17,32 +17,22 @@ package org.thingsboard.server.common.data.ai.provider;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "provider",
visible = true
include = JsonTypeInfo.As.PROPERTY,
property = "provider"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"),
@JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"),
@JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI")
})
public abstract class AiProviderConfig {
public sealed interface AiProviderConfig
permits OpenAiProviderConfig, GoogleAiGeminiProviderConfig, MistralAiProviderConfig {
public abstract AiProvider getProvider();
AiProvider provider();
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "API key for authenticating with the AI provider"
)
private String apiKey;
String apiKey();
}

View File

@ -15,26 +15,16 @@
*/
package org.thingsboard.server.common.data.ai.provider;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
public record GoogleAiGeminiProviderConfig(String apiKey) implements AiProviderConfig {
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(
name = "GoogleAiGeminiProviderConfig",
description = "Configuration for the Google AI Gemini provider"
)
public final class GoogleAiGeminiProviderConfig extends AiProviderConfig {
@Override
public AiProvider provider() {
return AiProvider.GOOGLE_AI_GEMINI;
}
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Name of the AI provider",
example = "GOOGLE_AI_GEMINI",
allowableValues = "GOOGLE_AI_GEMINI",
type = "string"
)
private AiProvider provider = AiProvider.GOOGLE_AI_GEMINI;
@Override
public String apiKey() {
return apiKey;
}
}

View File

@ -15,26 +15,16 @@
*/
package org.thingsboard.server.common.data.ai.provider;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
public record MistralAiProviderConfig(String apiKey) implements AiProviderConfig {
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(
name = "MistralAiProviderConfig",
description = "Configuration for the Mistral AI provider"
)
public final class MistralAiProviderConfig extends AiProviderConfig {
@Override
public AiProvider provider() {
return AiProvider.MISTRAL_AI;
}
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Name of the AI provider",
example = "MISTRAL_AI",
allowableValues = "MISTRAL_AI",
type = "string"
)
private AiProvider provider = AiProvider.MISTRAL_AI;
@Override
public String apiKey() {
return apiKey;
}
}

View File

@ -15,26 +15,16 @@
*/
package org.thingsboard.server.common.data.ai.provider;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
public record OpenAiProviderConfig(String apiKey) implements AiProviderConfig {
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(
name = "OpenAiProviderConfig",
description = "Configuration for the OpenAI provider"
)
public final class OpenAiProviderConfig extends AiProviderConfig {
@Override
public AiProvider provider() {
return AiProvider.OPENAI;
}
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
description = "Name of the AI provider",
example = "OPENAI",
allowableValues = "OPENAI",
type = "string"
)
private AiProvider provider = AiProvider.OPENAI;
@Override
public String apiKey() {
return apiKey;
}
}

View File

@ -23,29 +23,29 @@ import org.thingsboard.server.common.data.EntityType;
import java.io.Serial;
import java.util.UUID;
public final class AiSettingsId extends UUIDBased implements EntityId {
public final class AiModelSettingsId extends UUIDBased implements EntityId {
@Serial
private static final long serialVersionUID = 3021036138554389754L;
@JsonCreator
public AiSettingsId(@JsonProperty("id") UUID id) {
public AiModelSettingsId(@JsonProperty("id") UUID id) {
super(id);
}
@Override
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
description = "Entity type of the AI settings",
example = "AI_SETTINGS",
allowableValues = "AI_SETTINGS"
description = "Entity type of the AI model settings",
example = "AI_MODEL_SETTINGS",
allowableValues = "AI_MODEL_SETTINGS"
)
public EntityType getEntityType() {
return EntityType.AI_SETTINGS;
return EntityType.AI_MODEL_SETTINGS;
}
public static AiSettingsId fromString(String uuid) {
return new AiSettingsId(UUID.fromString(uuid));
public static AiModelSettingsId fromString(String uuid) {
return new AiModelSettingsId(UUID.fromString(uuid));
}
}

View File

@ -84,7 +84,7 @@ public class EntityIdFactory {
case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid);
case CALCULATED_FIELD -> new CalculatedFieldId(uuid);
case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid);
case AI_SETTINGS -> new AiSettingsId(uuid);
case AI_MODEL_SETTINGS -> new AiModelSettingsId(uuid);
default -> throw new IllegalArgumentException("EntityType " + type + " is not supported!");
};
}

View File

@ -63,7 +63,7 @@ enum EntityTypeProto {
MOBILE_APP_BUNDLE = 38;
CALCULATED_FIELD = 39;
CALCULATED_FIELD_LINK = 40;
AI_SETTINGS = 41;
AI_MODEL_SETTINGS = 41;
}
enum ApiUsageRecordKeyProto {

View File

@ -15,15 +15,15 @@
*/
package org.thingsboard.server.dao.ai;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Set;
record AiSettingsCacheEvictEvent(Set<AiSettingsCacheKey> keys) {
record AiModelSettingsCacheEvictEvent(Set<AiModelSettingsCacheKey> keys) {
static AiSettingsCacheEvictEvent of(TenantId tenantId, AiSettingsId aiSettingsId) {
return new AiSettingsCacheEvictEvent(Set.of(AiSettingsCacheKey.of(tenantId, aiSettingsId)));
static AiModelSettingsCacheEvictEvent of(TenantId tenantId, AiModelSettingsId settingsId) {
return new AiModelSettingsCacheEvictEvent(Set.of(AiModelSettingsCacheKey.of(tenantId, settingsId)));
}
}

View File

@ -17,22 +17,22 @@ 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.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.UUID;
import static java.util.Objects.requireNonNull;
record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements VersionedCacheKey {
record AiModelSettingsCacheKey(UUID tenantId, UUID settingsId) implements VersionedCacheKey {
AiSettingsCacheKey {
AiModelSettingsCacheKey {
requireNonNull(tenantId);
requireNonNull(aiSettingsId);
requireNonNull(settingsId);
}
static AiSettingsCacheKey of(TenantId tenantId, AiSettingsId aiSettingsId) {
return new AiSettingsCacheKey(tenantId.getId(), aiSettingsId.getId());
static AiModelSettingsCacheKey of(TenantId tenantId, AiModelSettingsId settingsId) {
return new AiModelSettingsCacheKey(tenantId.getId(), settingsId.getId());
}
@Override
@ -43,7 +43,7 @@ record AiSettingsCacheKey(UUID tenantId, UUID aiSettingsId) implements Versioned
@NonNull
@Override
public String toString() {
return /* cache name */ "_" + tenantId + "_" + aiSettingsId;
return /* cache name */ "_" + tenantId + "_" + settingsId;
}
}

View File

@ -20,14 +20,14 @@ 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.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
@Component("AiSettingsCache")
@Component("AiModelSettingsCache")
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true)
class AiSettingsCaffeineCache extends VersionedCaffeineTbCache<AiSettingsCacheKey, AiSettings> {
class AiModelSettingsCaffeineCache extends VersionedCaffeineTbCache<AiModelSettingsCacheKey, AiModelSettings> {
AiSettingsCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.AI_SETTINGS_CACHE);
AiModelSettingsCaffeineCache(CacheManager cacheManager) {
super(cacheManager, CacheConstants.AI_MODEL_SETTINGS_CACHE);
}
}

View File

@ -15,22 +15,22 @@
*/
package org.thingsboard.server.dao.ai;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.TenantEntityDao;
import java.util.Optional;
public interface AiSettingsDao extends Dao<AiSettings>, TenantEntityDao<AiSettings> {
public interface AiModelSettingsDao extends Dao<AiModelSettings>, TenantEntityDao<AiModelSettings> {
Optional<AiSettings> findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId);
Optional<AiModelSettings> findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId);
boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId);
boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId);
int deleteByTenantId(TenantId tenantId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId);
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId);
}

View File

@ -23,14 +23,14 @@ 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.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
@Component("AiSettingsCache")
@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis")
class AiSettingsRedisCache extends VersionedRedisTbCache<AiSettingsCacheKey, AiSettings> {
class AiModelSettingsRedisCache extends VersionedRedisTbCache<AiModelSettingsCacheKey, AiModelSettings> {
AiSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
super(CacheConstants.AI_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiSettings.class));
AiModelSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) {
super(CacheConstants.AI_MODEL_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModelSettings.class));
}
}

View File

@ -22,8 +22,8 @@ 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.AiSettings;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
@ -43,29 +43,29 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@Service
@RequiredArgsConstructor
class AiSettingsServiceImpl extends CachedVersionedEntityService<AiSettingsCacheKey, AiSettings, AiSettingsCacheEvictEvent> implements AiSettingsService {
class AiModelSettingsServiceImpl extends CachedVersionedEntityService<AiModelSettingsCacheKey, AiModelSettings, AiModelSettingsCacheEvictEvent> implements AiModelSettingsService {
private final DataValidator<AiSettings> aiSettingsValidator;
private final DataValidator<AiModelSettings> aiModelSettingsValidator;
private final JpaExecutorService jpaExecutor;
private final AiSettingsDao aiSettingsDao;
private final AiModelSettingsDao aiModelSettingsDao;
@Override
@TransactionalEventListener
public void handleEvictEvent(AiSettingsCacheEvictEvent event) {
public void handleEvictEvent(AiModelSettingsCacheEvictEvent event) {
cache.evict(event.keys());
}
@Override
@Transactional
public AiSettings save(AiSettings aiSettings) {
AiSettings oldSettings = aiSettingsValidator.validate(aiSettings, AiSettings::getTenantId);
public AiModelSettings save(AiModelSettings settings) {
AiModelSettings oldSettings = aiModelSettingsValidator.validate(settings, AiModelSettings::getTenantId);
AiSettings savedSettings;
AiModelSettings savedSettings;
try {
savedSettings = aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings);
savedSettings = aiModelSettingsDao.saveAndFlush(settings.getTenantId(), settings);
} catch (Exception e) {
checkConstraintViolation(e, "ai_settings_name_unq_key", "AI settings record with such name already exists!");
checkConstraintViolation(e, "ai_model_settings_name_unq_key", "AI model settings with such name already exist!");
throw e;
}
@ -78,65 +78,65 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService<AiSettingsCache
.broadcastEvent(true)
.build());
publishEvictEvent(AiSettingsCacheEvictEvent.of(savedSettings.getTenantId(), savedSettings.getId()));
publishEvictEvent(AiModelSettingsCacheEvictEvent.of(savedSettings.getTenantId(), savedSettings.getId()));
return savedSettings;
}
@Override
public Optional<AiSettings> findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId) {
return Optional.ofNullable(aiSettingsDao.findById(tenantId, aiSettingsId.getId()));
public Optional<AiModelSettings> findAiModelSettingsById(TenantId tenantId, AiModelSettingsId settingsId) {
return Optional.ofNullable(aiModelSettingsDao.findById(tenantId, settingsId.getId()));
}
@Override
public PageData<AiSettings> findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink) {
public PageData<AiModelSettings> findAiModelSettingsByTenantId(TenantId tenantId, PageLink pageLink) {
validatePageLink(pageLink);
return aiSettingsDao.findAllByTenantId(tenantId, pageLink);
return aiModelSettingsDao.findAllByTenantId(tenantId, pageLink);
}
@Override
public Optional<AiSettings> findAiSettingsByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) {
var cacheKey = AiSettingsCacheKey.of(tenantId, aiSettingsId);
return Optional.ofNullable(cache.get(cacheKey, () -> aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId).orElse(null)));
public Optional<AiModelSettings> findAiModelSettingsByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) {
var cacheKey = AiModelSettingsCacheKey.of(tenantId, settingsId);
return Optional.ofNullable(cache.get(cacheKey, () -> aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId).orElse(null)));
}
@Override
public FluentFuture<Optional<AiSettings>> findAiSettingsByTenantIdAndIdAsync(TenantId tenantId, AiSettingsId aiSettingsId) {
return FluentFuture.from(jpaExecutor.submit(() -> findAiSettingsByTenantIdAndId(tenantId, aiSettingsId)));
public FluentFuture<Optional<AiModelSettings>> findAiModelSettingsByTenantIdAndIdAsync(TenantId tenantId, AiModelSettingsId settingsId) {
return FluentFuture.from(jpaExecutor.submit(() -> findAiModelSettingsByTenantIdAndId(tenantId, settingsId)));
}
@Override
@Transactional
public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) {
return deleteByTenantIdAndIdInternal(tenantId, aiSettingsId);
public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) {
return deleteByTenantIdAndIdInternal(tenantId, settingsId);
}
@Override
public Optional<HasId<?>> findEntity(TenantId tenantId, EntityId entityId) {
return findAiSettingsByTenantIdAndId(tenantId, (AiSettingsId) entityId)
.map(aiSettings -> aiSettings); // necessary to cast to HasId<?>
return findAiModelSettingsByTenantIdAndId(tenantId, (AiModelSettingsId) entityId)
.map(settings -> settings); // necessary to cast to HasId<?>
}
@Override
public long countByTenantId(TenantId tenantId) {
return aiSettingsDao.countByTenantId(tenantId);
return aiModelSettingsDao.countByTenantId(tenantId);
}
@Override
@Transactional
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteByTenantIdAndIdInternal(tenantId, new AiSettingsId(id.getId()));
deleteByTenantIdAndIdInternal(tenantId, new AiModelSettingsId(id.getId()));
}
private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiSettingsId aiSettingsId) {
Optional<AiSettings> aiSettingsOpt = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettingsId);
if (aiSettingsOpt.isEmpty()) {
private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelSettingsId settingsId) {
Optional<AiModelSettings> toDeleteOpt = aiModelSettingsDao.findByTenantIdAndId(tenantId, settingsId);
if (toDeleteOpt.isEmpty()) {
return false;
}
boolean deleted = aiSettingsDao.deleteByTenantIdAndId(tenantId, aiSettingsId);
boolean deleted = aiModelSettingsDao.deleteByTenantIdAndId(tenantId, settingsId);
if (deleted) {
publishDeleteEvent(aiSettingsOpt.get());
publishEvictEvent(AiSettingsCacheEvictEvent.of(tenantId, aiSettingsId));
publishDeleteEvent(toDeleteOpt.get());
publishEvictEvent(AiModelSettingsCacheEvictEvent.of(tenantId, settingsId));
}
return deleted;
}
@ -144,23 +144,23 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService<AiSettingsCache
@Override
@Transactional
public void deleteByTenantId(TenantId tenantId) {
List<AiSettings> deletedSettings = aiSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData();
if (deletedSettings.isEmpty()) {
List<AiModelSettings> toDelete = aiModelSettingsDao.findAllByTenantId(tenantId, new PageLink(Integer.MAX_VALUE)).getData();
if (toDelete.isEmpty()) {
return;
}
aiSettingsDao.deleteByTenantId(tenantId);
aiModelSettingsDao.deleteByTenantId(tenantId);
Set<AiSettingsCacheKey> cacheKeys = Sets.newHashSetWithExpectedSize(deletedSettings.size());
deletedSettings.forEach(settings -> {
Set<AiModelSettingsCacheKey> cacheKeys = Sets.newHashSetWithExpectedSize(toDelete.size());
toDelete.forEach(settings -> {
publishDeleteEvent(settings);
cacheKeys.add(AiSettingsCacheKey.of(settings.getTenantId(), settings.getId()));
cacheKeys.add(AiModelSettingsCacheKey.of(settings.getTenantId(), settings.getId()));
});
publishEvictEvent(new AiSettingsCacheEvictEvent(cacheKeys));
publishEvictEvent(new AiModelSettingsCacheEvictEvent(cacheKeys));
}
private void publishDeleteEvent(AiSettings settings) {
private void publishDeleteEvent(AiModelSettings settings) {
eventPublisher.publishEvent(DeleteEntityEvent.builder()
.tenantId(settings.getTenantId())
.entityId(settings.getId())
@ -170,7 +170,7 @@ class AiSettingsServiceImpl extends CachedVersionedEntityService<AiSettingsCache
@Override
public EntityType getEntityType() {
return EntityType.AI_SETTINGS;
return EntityType.AI_MODEL_SETTINGS;
}
}

View File

@ -46,7 +46,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.AI_SETTINGS
EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL_SETTINGS
);
@TransactionalEventListener(fallbackExecution = true) // after transaction commit

View File

@ -740,15 +740,12 @@ public class ModelConstants {
public static final String CALCULATED_FIELD_LINK_CALCULATED_FIELD_ID = "calculated_field_id";
/**
* AI settings constants.
* AI model settings constants.
*/
public static final String AI_SETTINGS_TABLE_NAME = "ai_settings";
public static final String AI_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN;
public static final String AI_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY;
public static final String AI_SETTINGS_PROVIDER_COLUMN_NAME = "provider";
public static final String AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME = "provider_config";
public static final String AI_SETTINGS_MODEL_COLUMN_NAME = "model";
public static final String AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME = "model_config";
public static final String AI_MODEL_SETTINGS_TABLE_NAME = "ai_model_settings";
public static final String AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN;
public static final String AI_MODEL_SETTINGS_NAME_COLUMN_NAME = NAME_PROPERTY;
public static final String AI_MODEL_SETTINGS_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};

View File

@ -18,23 +18,20 @@ 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.EnumType;
import jakarta.persistence.Enumerated;
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.AiSettings;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.ai.model.AiModel;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
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.Map;
import java.util.Objects;
import java.util.UUID;
@ -42,53 +39,40 @@ import java.util.UUID;
@Setter
@ToString
@Entity
@Table(name = ModelConstants.AI_SETTINGS_TABLE_NAME)
public class AiSettingsEntity extends BaseVersionedEntity<AiSettings> {
@Table(name = ModelConstants.AI_MODEL_SETTINGS_TABLE_NAME)
public class AiModelSettingsEntity extends BaseVersionedEntity<AiModelSettings> {
@Column(name = ModelConstants.AI_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID")
public static final Map<String, String> COLUMN_MAP = Map.of(
"createdTime", "created_time"
);
@Column(name = ModelConstants.AI_MODEL_SETTINGS_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID")
private UUID tenantId;
@Column(name = ModelConstants.AI_SETTINGS_NAME_COLUMN_NAME, nullable = false)
@Column(name = ModelConstants.AI_MODEL_SETTINGS_NAME_COLUMN_NAME, nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(name = ModelConstants.AI_SETTINGS_PROVIDER_COLUMN_NAME, nullable = false)
private AiProvider provider;
@Type(JsonBinaryType.class)
@Column(name = ModelConstants.AI_SETTINGS_PROVIDER_CONFIG_COLUMN_NAME, nullable = false, columnDefinition = "JSONB")
private AiProviderConfig providerConfig;
@Column(name = ModelConstants.AI_MODEL_SETTINGS_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB")
private AiModel<?> configuration;
@Column(name = ModelConstants.AI_SETTINGS_MODEL_COLUMN_NAME, nullable = false)
private String model;
public AiModelSettingsEntity() {}
@Type(JsonBinaryType.class)
@Column(name = ModelConstants.AI_SETTINGS_MODEL_CONFIG_COLUMN_NAME, columnDefinition = "JSONB")
private AiModelConfig modelConfig;
public AiSettingsEntity() {}
public AiSettingsEntity(AiSettings aiSettings) {
super(aiSettings);
tenantId = getTenantUuid(aiSettings.getTenantId());
name = aiSettings.getName();
provider = aiSettings.getProvider();
providerConfig = aiSettings.getProviderConfig();
model = aiSettings.getModel();
modelConfig = aiSettings.getModelConfig();
public AiModelSettingsEntity(AiModelSettings aiModelSettings) {
super(aiModelSettings);
tenantId = getTenantUuid(aiModelSettings.getTenantId());
name = aiModelSettings.getName();
configuration = aiModelSettings.getConfiguration();
}
@Override
public AiSettings toData() {
var settings = new AiSettings(new AiSettingsId(id));
public AiModelSettings toData() {
var settings = new AiModelSettings(new AiModelSettingsId(id));
settings.setCreatedTime(createdTime);
settings.setVersion(version);
settings.setTenantId(TenantId.fromUUID(tenantId));
settings.setName(name);
settings.setProvider(provider);
settings.setProviderConfig(providerConfig);
settings.setModel(model);
settings.setModelConfig(modelConfig);
settings.setConfiguration(configuration);
return settings;
}
@ -99,7 +83,7 @@ public class AiSettingsEntity extends BaseVersionedEntity<AiSettings> {
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;
AiSettingsEntity that = (AiSettingsEntity) o;
AiModelSettingsEntity that = (AiModelSettingsEntity) o;
return getId() != null && Objects.equals(getId(), that.getId());
}

View File

@ -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.dao.service.validator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelSettingsDao;
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 AiModelSettingsDataValidator extends DataValidator<AiModelSettings> {
private final TenantService tenantService;
private final AiModelSettingsDao aiModelSettingsDao;
@Override
protected void validateCreate(TenantId tenantId, AiModelSettings settings) {
validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_MODEL_SETTINGS);
}
@Override
protected AiModelSettings validateUpdate(TenantId tenantId, AiModelSettings settings) {
Optional<AiModelSettings> existing = aiModelSettingsDao.findByTenantIdAndId(tenantId, settings.getId());
if (existing.isEmpty()) {
throw new DataValidationException("Cannot update non-existent AI model settings!");
}
return existing.get();
}
@Override
protected void validateDataImpl(TenantId tenantId, AiModelSettings settings) {
// ID validation
if (settings.getId() != null) {
if (settings.getUuidId() == null) {
throw new DataValidationException("AI model settings UUID should be specified!");
}
if (settings.getId().isNullUid()) {
throw new DataValidationException("AI model settings UUID must not be the reserved null value!");
}
}
// tenant ID validation
if (settings.getTenantId() == null || settings.getTenantId().getId() == null) {
throw new DataValidationException("AI model settings should be assigned to tenant!");
}
if (settings.getTenantId().isSysTenantId()) {
throw new DataValidationException("AI model settings cannot be assigned to the system tenant!");
}
if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model settings reference a non-existent tenant!");
}
// name validation
validateString("AI model settings name", settings.getName());
if (settings.getName().length() > 255) {
throw new DataValidationException("AI model settings name should be between 1 and 255 symbols!");
}
// model config validation
if (settings.getConfiguration() == null) {
throw new DataValidationException("AI model settings configuration should be specified!");
}
}
}

View File

@ -1,109 +0,0 @@
/**
* 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.EntityType;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiSettingsDao;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService;
import java.util.Objects;
import java.util.Optional;
@Component
@RequiredArgsConstructor
class AiSettingsDataValidator extends DataValidator<AiSettings> {
private final TenantService tenantService;
private final AiSettingsDao aiSettingsDao;
@Override
protected void validateCreate(TenantId tenantId, AiSettings aiSettings) {
validateNumberOfEntitiesPerTenant(tenantId, EntityType.AI_SETTINGS);
}
@Override
protected AiSettings validateUpdate(TenantId tenantId, AiSettings aiSettings) {
Optional<AiSettings> old = aiSettingsDao.findByTenantIdAndId(tenantId, aiSettings.getId());
if (old.isEmpty()) {
throw new DataValidationException("Cannot update non-existent AI settings!");
}
return old.get();
}
@Override
protected void validateDataImpl(TenantId tenantId, AiSettings aiSettings) {
// ID validation
if (aiSettings.getId() != null) {
if (aiSettings.getUuidId() == null) {
throw new DataValidationException("AI settings UUID should be specified!");
}
if (aiSettings.getId().isNullUid()) {
throw new DataValidationException("AI settings UUID must not be the reserved null value!");
}
}
// tenant ID validation
if (aiSettings.getTenantId() == null || aiSettings.getTenantId().getId() == null) {
throw new DataValidationException("AI settings should be assigned to tenant!");
}
if (aiSettings.getTenantId().isSysTenantId()) {
throw new DataValidationException("AI settings cannot be assigned to the system tenant!");
}
if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI settings reference a non-existent tenant!");
}
// name validation
validateString("AI settings name", aiSettings.getName());
if (aiSettings.getName().length() > 255) {
throw new DataValidationException("AI settings name should be between 1 and 255 symbols!");
}
// provider validation
if (aiSettings.getProvider() == null) {
throw new DataValidationException("AI provider should be specified!");
}
// provider config validation
if (aiSettings.getProviderConfig() == null) {
throw new DataValidationException("AI provider config should be specified!");
}
if (aiSettings.getProviderConfig().getProvider() != aiSettings.getProvider()) {
throw new DataValidationException("AI provider configuration should match the selected AI provider!");
}
validateString("AI provider API key", aiSettings.getProviderConfig().getApiKey());
// model identifier validation
validateString("AI model identifier", aiSettings.getModel());
if (aiSettings.getModel().length() > 255) {
throw new DataValidationException("AI model identifier should be between 1 and 255 symbols!");
}
// model config validation
if (aiSettings.getModelConfig() != null) {
if (!Objects.equals(aiSettings.getModelConfig().getModel(), aiSettings.getModel())) {
throw new DataValidationException("AI model configuration should match the selected AI model!");
}
}
}
}

View File

@ -22,30 +22,32 @@ 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.model.sql.AiSettingsEntity;
import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public interface AiSettingsRepository extends JpaRepository<AiSettingsEntity, UUID> {
interface AiModelSettingsRepository extends JpaRepository<AiModelSettingsEntity, UUID> {
@Query("SELECT ai " +
"FROM AiSettingsEntity ai " +
"WHERE ai.tenantId = :tenantId " +
"AND (:textSearch IS NULL " +
"OR ilike(ai.name, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(ai.provider, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(ai.model, CONCAT('%', :textSearch, '%')) = true)")
Page<AiSettingsEntity> findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable);
@Query(nativeQuery = true, value = """
SELECT *
FROM ai_model_settings ai_model
WHERE ai_model.tenant_id = :tenantId
AND (:textSearch IS NULL
OR ai_model.name ILIKE '%' || :textSearch || '%'
OR (ai_model.configuration -> 'providerConfig' ->> 'provider') ILIKE '%' || :textSearch || '%'
OR (ai_model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%')
""")
Page<AiModelSettingsEntity> findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable);
Optional<AiSettingsEntity> findByTenantIdAndId(UUID tenantId, UUID id);
Optional<AiModelSettingsEntity> findByTenantIdAndId(UUID tenantId, UUID id);
long countByTenantId(UUID tenantId);
@Transactional
@Modifying
@Query("DELETE FROM AiSettingsEntity ai WHERE ai.id IN (:ids)")
@Query("DELETE FROM AiModelSettingsEntity ai_model WHERE ai_model.id IN (:ids)")
int deleteByIdIn(@Param("ids") Set<UUID> ids);
@Transactional
@ -53,7 +55,7 @@ public interface AiSettingsRepository extends JpaRepository<AiSettingsEntity, UU
@Transactional
@Modifying
@Query("DELETE FROM AiSettingsEntity ai WHERE ai.tenantId = :tenantId AND ai.id IN (:ids)")
@Query("DELETE FROM AiModelSettingsEntity ai_model WHERE ai_model.tenantId = :tenantId AND ai_model.id IN (:ids)")
int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set<UUID> ids);
}

View File

@ -20,14 +20,14 @@ import org.apache.commons.lang3.StringUtils;
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.AiSettings;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
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.DaoUtil;
import org.thingsboard.server.dao.ai.AiSettingsDao;
import org.thingsboard.server.dao.model.sql.AiSettingsEntity;
import org.thingsboard.server.dao.ai.AiModelSettingsDao;
import org.thingsboard.server.dao.model.sql.AiModelSettingsEntity;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
@ -38,55 +38,55 @@ import java.util.UUID;
@SqlDao
@Component
@RequiredArgsConstructor
class JpaAiSettingsDao extends JpaAbstractDao<AiSettingsEntity, AiSettings> implements AiSettingsDao {
class JpaAiSettingsDao extends JpaAbstractDao<AiModelSettingsEntity, AiModelSettings> implements AiModelSettingsDao {
private final AiSettingsRepository aiSettingsRepository;
private final AiModelSettingsRepository aiModelSettingsRepository;
@Override
public Optional<AiSettings> findByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) {
return aiSettingsRepository.findByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()).map(DaoUtil::getData);
public Optional<AiModelSettings> findByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) {
return aiModelSettingsRepository.findByTenantIdAndId(tenantId.getId(), settingsId.getId()).map(DaoUtil::getData);
}
@Override
public PageData<AiSettings> findAllByTenantId(TenantId tenantId, PageLink pageLink) {
return DaoUtil.toPageData(aiSettingsRepository.findByTenantId(
tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink))
public PageData<AiModelSettings> findAllByTenantId(TenantId tenantId, PageLink pageLink) {
return DaoUtil.toPageData(aiModelSettingsRepository.findByTenantId(
tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink, AiModelSettingsEntity.COLUMN_MAP))
);
}
@Override
public Long countByTenantId(TenantId tenantId) {
return aiSettingsRepository.countByTenantId(tenantId.getId());
return aiModelSettingsRepository.countByTenantId(tenantId.getId());
}
@Override
public boolean deleteById(TenantId tenantId, AiSettingsId aiSettingsId) {
return aiSettingsRepository.deleteByIdIn(Set.of(aiSettingsId.getId())) > 0;
public boolean deleteById(TenantId tenantId, AiModelSettingsId settingsId) {
return aiModelSettingsRepository.deleteByIdIn(Set.of(settingsId.getId())) > 0;
}
@Override
public int deleteByTenantId(TenantId tenantId) {
return aiSettingsRepository.deleteByTenantId(tenantId.getId());
return aiModelSettingsRepository.deleteByTenantId(tenantId.getId());
}
@Override
public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) {
return aiSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(aiSettingsId.getId())) > 0;
public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelSettingsId settingsId) {
return aiModelSettingsRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(settingsId.getId())) > 0;
}
@Override
public EntityType getEntityType() {
return EntityType.AI_SETTINGS;
return EntityType.AI_MODEL_SETTINGS;
}
@Override
protected Class<AiSettingsEntity> getEntityClass() {
return AiSettingsEntity.class;
protected Class<AiModelSettingsEntity> getEntityClass() {
return AiModelSettingsEntity.class;
}
@Override
protected JpaRepository<AiSettingsEntity, UUID> getRepository() {
return aiSettingsRepository;
protected JpaRepository<AiModelSettingsEntity, UUID> getRepository() {
return aiModelSettingsRepository;
}
}

View File

@ -183,7 +183,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
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.AI_SETTINGS
EntityType.AI_MODEL_SETTINGS
);
}

View File

@ -949,15 +949,12 @@ CREATE TABLE IF NOT EXISTS cf_debug_event (
e_error varchar
) PARTITION BY RANGE (ts);
CREATE TABLE IF NOT EXISTS ai_settings (
CREATE TABLE IF NOT EXISTS ai_model_settings (
id UUID NOT NULL PRIMARY KEY,
created_time BIGINT NOT NULL,
tenant_id UUID NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
provider VARCHAR(255) NOT NULL,
provider_config JSONB NOT NULL,
model VARCHAR(255) NOT NULL,
model_config JSONB,
CONSTRAINT ai_settings_name_unq_key UNIQUE (tenant_id, name)
configuration JSONB NOT NULL,
CONSTRAINT ai_model_settings_name_unq_key UNIQUE (tenant_id, name)
);

View File

@ -16,15 +16,11 @@
package org.thingsboard.rule.engine.api;
import dev.langchain4j.model.chat.ChatModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
public interface RuleEngineAiService {
public interface RuleEngineAiModelService {
ChatModel configureChatModel(TenantId tenantId, AiSettingsId aiSettingsId);
ChatModel configureChatModel(AiProviderConfig providerConfig, AiModelConfig modelConfig);
<C extends AiChatModelConfig<C>> ChatModel configureChatModel(AiChatModel<C> chatModel);
}

View File

@ -44,7 +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.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService;
@ -418,9 +418,9 @@ public interface TbContext {
AuditLogService getAuditLogService();
RuleEngineAiService getAiService();
RuleEngineAiModelService getAiModelService();
AiSettingsService getAiSettingsService();
AiModelSettingsService getAiModelSettingsService();
AiRequestsExecutor getAiRequestsExecutor();

View File

@ -36,9 +36,11 @@ 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.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.provider.AiProviderConfig;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModel;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.msg.TbMsg;
@ -46,6 +48,7 @@ import org.thingsboard.server.dao.exception.DataValidationException;
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.server.dao.service.ConstraintValidator.validateFields;
@ -64,7 +67,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
private String userPrompt;
private ResponseFormat responseFormat;
private int timeoutSeconds;
private AiSettingsId aiSettingsId;
private AiModelSettingsId modelSettingsId;
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
@ -86,11 +89,16 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
systemPrompt = config.getSystemPrompt();
userPrompt = config.getUserPrompt();
timeoutSeconds = config.getTimeoutSeconds();
modelSettingsId = config.getAiModelSettingsId();
if (!aiSettingsExist(ctx, config.getAiSettingsId())) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI settings with ID: " + config.getAiSettingsId() + " were not found", true);
Optional<AiModelSettings> modelSettings = ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndId(ctx.getTenantId(), modelSettingsId);
if (modelSettings.isEmpty()) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found", true);
}
AiModelType modelType = modelSettings.get().getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType, true);
}
aiSettingsId = config.getAiSettingsId();
}
private static JsonSchema getJsonSchema(ResponseFormatType responseFormatType, ObjectNode jsonSchema) {
@ -100,12 +108,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
return responseFormatType == ResponseFormatType.JSON && jsonSchema != null ? Langchain4jJsonSchemaAdapter.fromJsonNode(jsonSchema) : null;
}
private static boolean aiSettingsExist(TbContext ctx, AiSettingsId aiSettingsId) {
return ctx.getAiSettingsService().findAiSettingsByTenantIdAndId(ctx.getTenantId(), aiSettingsId).isPresent();
}
@Override
public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException {
public void onMsg(TbContext ctx, TbMsg msg) {
var ackedMsg = ackIfNeeded(ctx, msg);
var systemMessage = SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg));
@ -137,19 +141,25 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
}, directExecutor());
}
private FluentFuture<ChatModel> configureChatModelAsync(TbContext ctx) {
return ctx.getAiSettingsService().findAiSettingsByTenantIdAndIdAsync(ctx.getTenantId(), aiSettingsId).transform(aiSettingsOpt -> {
if (aiSettingsOpt.isEmpty()) {
throw new NoSuchElementException("AI settings with ID: " + aiSettingsId + " were not found");
private <C extends AiChatModelConfig<C>> FluentFuture<ChatModel> configureChatModelAsync(TbContext ctx) {
return ctx.getAiModelSettingsService().findAiModelSettingsByTenantIdAndIdAsync(ctx.getTenantId(), modelSettingsId).transform(settingsOpt -> {
if (settingsOpt.isEmpty()) {
throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] were not found");
}
AiModelSettings settings = settingsOpt.get();
AiModelType modelType = settings.getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model settings with ID: [" + modelSettingsId + "] must be of type CHAT, but was " + modelType);
}
AiProviderConfig providerConfig = aiSettingsOpt.get().getProviderConfig();
AiModelConfig modelConfig = aiSettingsOpt.get().getModelConfig();
@SuppressWarnings("unchecked")
AiChatModel<C> chatModel = (AiChatModel<C>) settingsOpt.get().getConfiguration();
modelConfig.setTimeoutSeconds(timeoutSeconds);
modelConfig.setMaxRetries(0); // disable retries to respect timeout set in rule node config
chatModel = chatModel.withModelConfig(chatModel.modelConfig()
.withTimeoutSeconds(timeoutSeconds)
.withMaxRetries(0)); // disable retries to respect timeout set in rule node config
return ctx.getAiService().configureChatModel(providerConfig, modelConfig);
return ctx.getAiModelService().configureChatModel(chatModel);
}, ctx.getDbCallbackExecutor());
}
@ -172,7 +182,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
systemPrompt = null;
userPrompt = null;
responseFormat = null;
aiSettingsId = null;
modelSettingsId = null;
}
}

View File

@ -26,14 +26,14 @@ import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.thingsboard.common.util.JsonSchemaUtils;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.validation.Length;
@Data
public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfiguration> {
@NotNull
private AiSettingsId aiSettingsId;
private AiModelSettingsId aiModelSettingsId;
@NotBlank
@Length(min = 1, max = 1000)

View File

@ -19,7 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.id.AiSettingsId;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.AssetId;
@ -176,8 +176,8 @@ public class TenantIdLoader {
tenantEntity = null;
}
break;
case AI_SETTINGS:
tenantEntity = ctx.getAiSettingsService().findAiSettingsById(ctxTenantId, new AiSettingsId(id)).orElse(null);
case AI_MODEL_SETTINGS:
tenantEntity = ctx.getAiModelSettingsService().findAiModelSettingsById(ctxTenantId, new AiModelSettingsId(id)).orElse(null);
break;
default:
throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType());

View File

@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.OtaPackage;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiSettings;
import org.thingsboard.server.common.data.ai.AiModelSettings;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
@ -69,7 +69,7 @@ import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.widget.WidgetType;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.ai.AiSettingsService;
import org.thingsboard.server.dao.ai.AiModelSettingsService;
import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
@ -164,7 +164,7 @@ public class TenantIdLoaderTest {
@Mock
private CalculatedFieldService calculatedFieldService;
@Mock
private AiSettingsService aiSettingsService;
private AiModelSettingsService aiModelSettingsService;
private TenantId tenantId;
private TenantProfileId tenantProfileId;
@ -424,11 +424,11 @@ public class TenantIdLoaderTest {
when(ctx.getCalculatedFieldService()).thenReturn(calculatedFieldService);
doReturn(calculatedFieldLink).when(calculatedFieldService).findCalculatedFieldLinkById(eq(tenantId), any());
break;
case AI_SETTINGS:
AiSettings aiSettings = new AiSettings();
aiSettings.setTenantId(tenantId);
when(ctx.getAiSettingsService()).thenReturn(aiSettingsService);
doReturn(Optional.of(aiSettings)).when(aiSettingsService).findAiSettingsById(eq(tenantId), any());
case AI_MODEL_SETTINGS:
AiModelSettings aiModelSettings = new AiModelSettings();
aiModelSettings.setTenantId(tenantId);
when(ctx.getAiModelSettingsService()).thenReturn(aiModelSettingsService);
doReturn(Optional.of(aiModelSettings)).when(aiModelSettingsService).findAiModelSettingsById(eq(tenantId), any());
break;
default:
throw new RuntimeException("Unexpected originator EntityType " + entityType);