AI rule node: fail node init if JSON mode is configured and the model does not support it

This commit is contained in:
Dmytro Skarzhynets 2025-07-15 13:25:20 +03:00
parent 28ce6ca8c8
commit 1ce1a1b89c
No known key found for this signature in database
GPG Key ID: 2B51652F224037DF
10 changed files with 62 additions and 10 deletions

View File

@ -42,4 +42,6 @@ public sealed interface AiChatModelConfig<C extends AiChatModelConfig<C>> extend
C withMaxRetries(Integer maxRetries); C withMaxRetries(Integer maxRetries);
boolean supportsJsonMode();
} }

View File

@ -48,4 +48,9 @@ public record AmazonBedrockChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return false;
}
} }

View File

@ -49,4 +49,9 @@ public record AnthropicChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return false;
}
} }

View File

@ -50,4 +50,9 @@ public record AzureOpenAiChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return true;
}
} }

View File

@ -50,4 +50,9 @@ public record GitHubModelsChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return false;
}
} }

View File

@ -51,4 +51,9 @@ public record GoogleAiGeminiChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return true;
}
} }

View File

@ -51,4 +51,9 @@ public record GoogleVertexAiGeminiChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return true;
}
} }

View File

@ -50,4 +50,9 @@ public record MistralAiChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return true;
}
} }

View File

@ -50,4 +50,9 @@ public record OpenAiChatModelConfig(
return configurer.configureChatModel(this); return configurer.configureChatModel(this);
} }
@Override
public boolean supportsJsonMode() {
return true;
}
} }

View File

@ -48,6 +48,7 @@ import java.util.NoSuchElementException;
import java.util.Optional; import java.util.Optional;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@RuleNode( @RuleNode(
@ -91,8 +92,21 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
throw new TbNodeException(e, true); throw new TbNodeException(e, true);
} }
Optional<AiModel> modelOpt = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId);
if (modelOpt.isEmpty()) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true);
}
AiModel model = modelOpt.get();
AiModelType modelType = model.getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true);
}
AiChatModelConfig<?> chatModelConfig = (AiChatModelConfig<?>) model.getConfiguration();
if (isJsonModeConfigured(config)) {
if (!chatModelConfig.supportsJsonMode()) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true);
}
// LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
if (config.getResponseFormat().type() == TbResponseFormat.TbResponseFormatType.JSON) {
responseFormat = config.getResponseFormat().toLangChainResponseFormat(); responseFormat = config.getResponseFormat().toLangChainResponseFormat();
} }
@ -101,15 +115,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
timeoutSeconds = config.getTimeoutSeconds(); timeoutSeconds = config.getTimeoutSeconds();
modelId = config.getModelId(); modelId = config.getModelId();
super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so
}
Optional<AiModel> model = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId); private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) {
if (model.isEmpty()) { var responseFormatType = config.getResponseFormat().type();
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true); return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA;
}
AiModelType modelType = model.get().getConfiguration().modelType();
if (modelType != AiModelType.CHAT) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true);
}
} }
@Override @Override