Browse Source

feat: support JSON Schema structured output across AI providers

Upgrade LangChain4j to 1.15.1-TB1 and migrate GitHub Models to the
official OpenAI integration (OpenAiOfficialChatModel), enabling JSON
Schema (strict) structured output for GitHub Models and exposing JSON
Schema as a response format for Anthropic and Amazon Bedrock.

- Rename model capability flags to supportsSchemalessJsonOutput() and
  supportsJsonSchemaOutput(), declared explicitly per provider
- Push response-format capability checks into TbResponseFormat and
  simplify response-format handling in TbAiNode
- Remove obsolete manual JSON escaping for GitHub Models, which now
  double-escaped requests under the official OpenAI SDK
- Drop the now-obsolete opennlp-tools version pin (the upgraded
  LangChain4j fork ships opennlp-tools >= 2.5.9)
- Update the AI rule node UI to offer response formats per provider,
  refresh available model lists, and migrate toggle options to @if
- Expand configurer tests to verify per-provider model configuration
pull/15728/head
Dmytro Skarzhynets 3 weeks ago
parent
commit
ba7fbfffc1
No known key found for this signature in database GPG Key ID: 2B51652F224037DF
  1. 14
      application/pom.xml
  2. 41
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  3. 115
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  4. 329
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  5. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
  6. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
  7. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
  8. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
  9. 9
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
  10. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
  11. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
  12. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
  13. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java
  14. 7
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
  15. 8
      pom.xml
  16. 18
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
  17. 20
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java
  18. 128
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
  19. 12
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html
  20. 19
      ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts
  21. 32
      ui-ngx/src/app/shared/models/ai-model.models.ts
  22. 6
      ui-ngx/src/assets/locale/locale.constant-da_DK.json
  23. 6
      ui-ngx/src/assets/locale/locale.constant-de_DE.json
  24. 6
      ui-ngx/src/assets/locale/locale.constant-el_GR.json
  25. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  26. 6
      ui-ngx/src/assets/locale/locale.constant-es_ES.json
  27. 6
      ui-ngx/src/assets/locale/locale.constant-fr_FR.json
  28. 6
      ui-ngx/src/assets/locale/locale.constant-it_IT.json
  29. 6
      ui-ngx/src/assets/locale/locale.constant-ja_JP.json
  30. 4
      ui-ngx/src/assets/locale/locale.constant-lt_LT.json
  31. 6
      ui-ngx/src/assets/locale/locale.constant-nl_NL.json
  32. 6
      ui-ngx/src/assets/locale/locale.constant-no_NO.json
  33. 6
      ui-ngx/src/assets/locale/locale.constant-pt_BR.json
  34. 6
      ui-ngx/src/assets/locale/locale.constant-tr_TR.json
  35. 6
      ui-ngx/src/assets/locale/locale.constant-uk_UA.json
  36. 6
      ui-ngx/src/assets/locale/locale.constant-zh_CN.json

14
application/pom.xml

@ -390,11 +390,7 @@
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-google-ai-gemini</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-vertex-ai-gemini</artifactId>
<artifactId>langchain4j-google-genai</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>
@ -410,13 +406,7 @@
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-github-models</artifactId>
<exclusions>
<exclusion>
<groupId>com.azure</groupId>
<artifactId>azure-core-test</artifactId>
</exclusion>
</exclusions>
<artifactId>langchain4j-open-ai-official</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard.langchain4j</groupId>

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

@ -15,14 +15,8 @@
*/
package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
@ -31,9 +25,6 @@ import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
class AiChatModelServiceImpl implements AiChatModelService {
@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService {
} catch (Throwable t) {
return FluentFuture.from(Futures.immediateFailedFuture(t));
}
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}
return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest);
}
private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) {
List<ChatMessage> messages = chatRequest.messages().stream()
.map(this::prepareUserMessage)
.collect(Collectors.toList());
return ChatRequest.builder()
.messages(messages)
.responseFormat(chatRequest.responseFormat())
.build();
}
private ChatMessage prepareUserMessage(ChatMessage message) {
if (message instanceof UserMessage userMessage) {
List<Content> newContents = userMessage.contents().stream()
.map(this::prepareContent)
.collect(Collectors.toList());
return UserMessage.from(newContents);
}
return message;
}
private Content prepareContent(Content content) {
if (content instanceof TextContent txt) {
return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text())));
}
return content;
}
}

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

@ -15,26 +15,18 @@
*/
package org.thingsboard.server.service.ai;
import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.api.gax.retrying.RetrySettings;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.vertexai.Transport;
import com.google.cloud.vertexai.VertexAI;
import com.google.cloud.vertexai.api.GenerationConfig;
import com.google.cloud.vertexai.api.PredictionServiceClient;
import com.google.cloud.vertexai.api.PredictionServiceSettings;
import com.google.cloud.vertexai.generativeai.GenerativeModel;
import dev.langchain4j.model.anthropic.AnthropicChatModel;
import dev.langchain4j.model.azure.AzureOpenAiChatModel;
import dev.langchain4j.model.bedrock.BedrockChatModel;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequestParameters;
import dev.langchain4j.model.github.GitHubModelsChatModel;
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
import dev.langchain4j.model.google.genai.GoogleGenAiChatModel;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.SsrfProtectionValidator;
@ -50,7 +42,6 @@ import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@ -107,14 +98,12 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) {
return GoogleAiGeminiChatModel.builder()
return GoogleGenAiChatModel.builder()
.apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.topK(chatModelConfig.topK())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxOutputTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
@ -123,84 +112,26 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) {
GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig();
// construct service account credentials using service account key JSON
ServiceAccountCredentials serviceAccountCredentials;
GoogleCredentials credentials;
try {
serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes()));
credentials = ServiceAccountCredentials
.fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8)))
.createScoped("https://www.googleapis.com/auth/cloud-platform");
} catch (IOException e) {
throw new RuntimeException("Failed to parse service account key JSON", e);
}
PredictionServiceSettings predictionServiceClientSettings;
try {
// create prediction service settings for REST transport with service account key credentials
PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder()
.setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials));
// get the retry settings that control request timeout for generateContent RPC
RetrySettings.Builder retrySettings = settingsBuilder
.generateContentSettings()
.getRetrySettings()
.toBuilder();
// set request timeout from model config
if (chatModelConfig.timeoutSeconds() != null) {
retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds()));
}
// set updated retry settings
settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build());
// build the client settings
predictionServiceClientSettings = settingsBuilder.build();
} catch (IOException e) {
throw new RuntimeException("Failed to create prediction service client settings", e);
}
// construct Vertex AI instance
var vertexAI = new VertexAI.Builder()
.setProjectId(providerConfig.projectId())
.setLocation(providerConfig.location())
.setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings))
.setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys
return GoogleGenAiChatModel.builder()
.projectId(chatModelConfig.providerConfig().projectId())
.location(chatModelConfig.providerConfig().location())
.googleCredentials(credentials)
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.topK(chatModelConfig.topK())
.maxOutputTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();
// map model config to generation config
var generationConfigBuilder = GenerationConfig.newBuilder();
if (chatModelConfig.temperature() != null) {
generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue());
}
if (chatModelConfig.topP() != null) {
generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue());
}
if (chatModelConfig.topK() != null) {
generationConfigBuilder.setTopK(chatModelConfig.topK());
}
if (chatModelConfig.frequencyPenalty() != null) {
generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue());
}
if (chatModelConfig.presencePenalty() != null) {
generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue());
}
if (chatModelConfig.maxOutputTokens() != null) {
generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens());
}
var generationConfig = generationConfigBuilder.build();
// construct generative model instance
var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig);
return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries());
}
private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) {
try {
return PredictionServiceClient.create(settings);
} catch (IOException e) {
throw new RuntimeException("Failed to create prediction service client", e);
}
}
@Override
@ -262,14 +193,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override
public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) {
return GitHubModelsChatModel.builder()
.gitHubToken(chatModelConfig.providerConfig().personalAccessToken())
return OpenAiOfficialChatModel.builder()
.isGitHubModels(true)
.strictJsonSchema(true)
.apiKey(chatModelConfig.providerConfig().personalAccessToken())
.modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature())
.topP(chatModelConfig.topP())
.frequencyPenalty(chatModelConfig.frequencyPenalty())
.presencePenalty(chatModelConfig.presencePenalty())
.maxTokens(chatModelConfig.maxOutputTokens())
.maxCompletionTokens(chatModelConfig.maxOutputTokens())
.timeout(toDuration(chatModelConfig.timeoutSeconds()))
.maxRetries(chatModelConfig.maxRetries())
.build();

329
application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java

@ -15,20 +15,29 @@
*/
package org.thingsboard.server.service.ai;
import com.google.cloud.vertexai.api.GenerationConfig;
import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequestParameters;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.SsrfProtectionValidator;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl();
@BeforeEach
void enableSsrfProtection() {
SsrfProtectionValidator.setEnabled(true);
}
@AfterEach
void disableSsrfProtection() {
void resetSsrfProtection() {
SsrfProtectionValidator.setEnabled(false);
}
// ============================== Configuration correctness (one per provider) ==============================
// For each provider we feed a fully populated config and assert that the returned ChatModel carries the same
// values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection.
@Test
void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() {
// GIVEN
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("https://api.openai.com/v1")
.apiKey("test-key")
.build())
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() {
// GIVEN
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key"))
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() {
// GIVEN
var config = GoogleAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleAiGeminiProviderConfig("test-key"))
.modelId("gemini-2.5-flash")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() {
// GIVEN
var config = GoogleVertexAiGeminiChatModelConfig.builder()
.providerConfig(new GoogleVertexAiGeminiProviderConfig(
"key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY))
.modelId("gemini-2.5-flash")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gemini-2.5-flash");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() {
// GIVEN
var config = MistralAiChatModelConfig.builder()
.providerConfig(new MistralAiProviderConfig("test-key"))
.modelId("mistral-large-latest")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("mistral-large-latest");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() {
// GIVEN
var config = AnthropicChatModelConfig.builder()
.providerConfig(new AnthropicProviderConfig("test-key"))
.modelId("claude-opus-4-8")
.temperature(0.7)
.topP(0.9)
.topK(40)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("claude-opus-4-8");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void configureChatModel_openAi_withPrivateIp_shouldThrow() {
void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() {
// GIVEN
var config = AmazonBedrockChatModelConfig.builder()
.providerConfig(new AmazonBedrockProviderConfig(
"us-east-1", "test-access-key-id", "test-secret-access-key"))
.modelId("anthropic.claude-3-5-sonnet-20240620-v1:0")
.temperature(0.7)
.topP(0.9)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.maxOutputTokens()).isEqualTo(500);
}
@Test
void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() {
// GIVEN
var config = GitHubModelsChatModelConfig.builder()
.providerConfig(new GitHubModelsProviderConfig("ghp-test-token"))
.modelId("gpt-4o")
.temperature(0.7)
.topP(0.9)
.frequencyPenalty(0.5)
.presencePenalty(0.25)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("gpt-4o");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.frequencyPenalty()).isEqualTo(0.5);
assertThat(params.presencePenalty()).isEqualTo(0.25);
assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens
}
@Test
void shouldConfigureOllamaModel_whenGivenOllamaConfig() {
// GIVEN
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.temperature(0.7)
.topP(0.9)
.topK(40)
.contextLength(4096)
.maxOutputTokens(500)
.timeoutSeconds(60)
.maxRetries(3)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(config);
// THEN
assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA);
ChatRequestParameters params = chatModel.defaultRequestParameters();
assertThat(params.modelName()).isEqualTo("llama3");
assertThat(params.temperature()).isEqualTo(0.7);
assertThat(params.topP()).isEqualTo(0.9);
assertThat(params.topK()).isEqualTo(40);
assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens
}
// ============================== Base URL SSRF validation ==============================
// Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses
// when SSRF protection is enabled.
@Test
void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://172.17.0.1:8080/")
@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_openAi_withLocalhostUrl_shouldThrow() {
void shouldThrow_whenOpenAiBaseUrlIsLocalhost() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OpenAiChatModelConfig.builder()
.providerConfig(OpenAiProviderConfig.builder()
.baseUrl("http://localhost:22/")
@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest {
.modelId("gpt-4o")
.build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() {
void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = AzureOpenAiChatModelConfig.builder()
.providerConfig(new AzureOpenAiProviderConfig(
"http://10.0.0.1:8080/", null, "test-key"))
.modelId("gpt-4o")
.build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_ollama_withPrivateIp_shouldThrow() {
void shouldThrow_whenOllamaBaseUrlIsPrivateIp() {
// GIVEN
SsrfProtectionValidator.setEnabled(true);
var config = OllamaChatModelConfig.builder()
.providerConfig(new OllamaProviderConfig(
"http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None()))
.modelId("llama3")
.build();
// WHEN / THEN
assertThatThrownBy(() -> configurer.configureChatModel(config))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("URI is invalid");
}
@Test
void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
// GIVEN
var providerConfig = new GoogleVertexAiGeminiProviderConfig(
"test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY
);
var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder()
.providerConfig(providerConfig)
.modelId("gemini-2.0-flash")
.frequencyPenalty(0.3)
.presencePenalty(0.7)
.build();
// WHEN
ChatModel chatModel = configurer.configureChatModel(chatModelConfig);
// THEN
var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig");
assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f);
assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f);
}
}

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

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

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

@ -49,8 +49,13 @@ public record AmazonBedrockChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return false;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}
}

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

@ -50,8 +50,13 @@ public record AnthropicChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return false;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}
}

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

@ -51,7 +51,12 @@ public record AzureOpenAiChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

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

@ -51,8 +51,13 @@ public record GitHubModelsChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
return false;
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}
}

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

@ -52,7 +52,12 @@ public record GoogleAiGeminiChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

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

@ -52,7 +52,12 @@ public record GoogleVertexAiGeminiChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

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

@ -51,7 +51,12 @@ public record MistralAiChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

7
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java

@ -51,7 +51,12 @@ public record OllamaChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

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

@ -51,7 +51,12 @@ public record OpenAiChatModelConfig(
}
@Override
public boolean supportsJsonMode() {
public boolean supportsSchemalessJsonOutput() {
return true;
}
@Override
public boolean supportsJsonSchemaOutput() {
return true;
}

8
pom.xml

@ -142,8 +142,7 @@
<jakarta.el.version>4.0.2</jakarta.el.version>
<antisamy.version>1.7.5</antisamy.version>
<snmp4j.version>3.8.0</snmp4j.version>
<langchain4j.version>1.8.0-TB</langchain4j.version>
<opennlp-tools.version>2.5.9</opennlp-tools.version> <!-- to fix CVE-2026-40682, CVE-2026-42027 in transitive dep via langchain4j-bom (which still pins 2.5.4). TODO: remove when langchain4j fork ships opennlp-tools >= 2.5.9 -->
<langchain4j.version>1.15.1-TB1</langchain4j.version>
<error_prone_annotations.version>2.38.0</error_prone_annotations.version>
<animal-sniffer-annotations.version>1.24</animal-sniffer-annotations.version>
<auto-value-annotations.version>1.11.0</auto-value-annotations.version>
@ -1376,11 +1375,6 @@
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>org.apache.opennlp</groupId>
<artifactId>opennlp-tools</artifactId>
<version>${opennlp-tools.version}</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>

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

@ -69,7 +69,6 @@ import java.util.Set;
import java.util.UUID;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@Slf4j
@ -93,7 +92,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
configDirective = "tbExternalNodeAiConfig",
iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==",
ruleChainTypes = RuleChainType.CORE,
docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/"
docUrl = "https://thingsboard.io/docs/reference/rule-engine/nodes/external/ai-request/"
)
public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
@ -127,13 +126,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true);
}
AiChatModelConfig<?> chatModelConfig = (AiChatModelConfig<?>) model.getConfiguration();
if (isJsonModeConfigured(config)) {
if (!chatModelConfig.supportsJsonMode()) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true);
}
// LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
responseFormat = config.getResponseFormat().toLangChainResponseFormat();
TbResponseFormat tbResponseFormat = config.getResponseFormat();
if (!tbResponseFormat.isSupportedBy(chatModelConfig)) {
throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + tbResponseFormat.type() + "' response format", true);
}
responseFormat = tbResponseFormat.toLangChainResponseFormat();
if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) {
resourceIds = new HashSet<>(config.getResourceIds().size());
for (UUID resourceId : config.getResourceIds()) {
@ -149,11 +146,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so
}
private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) {
var responseFormatType = config.getResponseFormat().type();
return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA;
}
@Override
public void onMsg(TbContext ctx, TbMsg msg) {
var ackedMsg = ackIfNeeded(ctx, msg);

20
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java

@ -20,7 +20,9 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@ -41,6 +43,9 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
TbResponseFormatType type();
boolean isSupportedBy(AiChatModelConfig<?> modelConfig);
@Nullable
ResponseFormat toLangChainResponseFormat();
enum TbResponseFormatType {
@ -58,6 +63,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.TEXT;
}
@Override
public boolean isSupportedBy(AiChatModelConfig<?> modelConfig) {
return true;
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.TEXT;
@ -72,6 +82,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.JSON;
}
@Override
public boolean isSupportedBy(AiChatModelConfig<?> modelConfig) {
return modelConfig.supportsSchemalessJsonOutput();
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.JSON;
@ -86,6 +101,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes
return TbResponseFormatType.JSON_SCHEMA;
}
@Override
public boolean isSupportedBy(AiChatModelConfig<?> modelConfig) {
return modelConfig.supportsJsonSchemaOutput();
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.builder()

128
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java

@ -38,8 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.DirectListeningExecutor;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat;
import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat;
@ -53,8 +53,10 @@ import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceDataInfo;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.AiModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.common.data.id.AiModelId;
@ -400,6 +402,124 @@ class TbAiNodeTest {
.matches(e -> ((TbNodeException) e).isUnrecoverable());
}
@Test
void givenJsonSchemaResponseFormatAndModelSupportsIt_whenInit_thenDoesNotThrow() {
// GIVEN
var jsonSchema = """
{
"title": "Joke",
"type": "object",
"properties": {
"joke": {
"type": "string"
}
},
"required": [
"joke"
]
}
""";
config = constructValidConfig();
config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema)));
// Anthropic does not support schemaless JSON mode, but does support JSON Schema constrained output
modelConfig = AnthropicChatModelConfig.builder()
.providerConfig(new AnthropicProviderConfig("test-api-key"))
.modelId("claude-sonnet-4-5")
.build();
model = AiModel.builder()
.tenantId(tenantId)
.name("Test model")
.configuration(modelConfig)
.build();
model.setId(modelId);
model.setVersion(1L);
model.setCreatedTime(123L);
given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
// WHEN-THEN
assertThatNoException()
.isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))));
}
@Test
void givenJsonSchemaResponseFormatAndBedrockModel_whenInit_thenDoesNotThrow() {
// GIVEN
var jsonSchema = """
{
"title": "Joke",
"type": "object",
"properties": {
"joke": {
"type": "string"
}
},
"required": [
"joke"
]
}
""";
config = constructValidConfig();
config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema)));
// Bedrock does not support schemaless JSON mode, but does support JSON Schema constrained output (Converse API)
modelConfig = AmazonBedrockChatModelConfig.builder()
.providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key"))
.modelId("anthropic.claude-sonnet-4-5")
.build();
model = AiModel.builder()
.tenantId(tenantId)
.name("Test model")
.configuration(modelConfig)
.build();
model.setId(modelId);
model.setVersion(1L);
model.setCreatedTime(123L);
given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
// WHEN-THEN
assertThatNoException()
.isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))));
}
@Test
void givenSchemalessJsonResponseFormatAndBedrockModel_whenInit_thenThrowsUnrecoverableTbNodeException() {
// GIVEN
config = constructValidConfig();
config.setResponseFormat(new TbJsonResponseFormat());
modelConfig = AmazonBedrockChatModelConfig.builder()
.providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key"))
.modelId("anthropic.claude-sonnet-4-5")
.build();
model = AiModel.builder()
.tenantId(tenantId)
.name("Test model")
.configuration(modelConfig)
.build();
model.setId(modelId);
model.setVersion(1L);
model.setCreatedTime(123L);
given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model));
// WHEN-THEN
assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format")
.matches(e -> ((TbNodeException) e).isUnrecoverable());
}
@Test
void givenNotExistingResources_whenInit_thenThrowsException() {
// GIVEN
@ -669,7 +789,7 @@ class TbAiNodeTest {
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt));
assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(userPrompt), new TextContent(textData),
new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png")));
return true;
@ -706,7 +826,7 @@ class TbAiNodeTest {
argThat(actualChatRequest -> {
assertThat(actualChatRequest.messages()).hasSize(2);
assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt()));
assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents())
assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents())
.containsAll(List.of(new TextContent(config.getUserPrompt())));
return true;
})
@ -993,7 +1113,7 @@ class TbAiNodeTest {
then(aiChatModelServiceMock).should().sendChatRequestAsync(
any(),
argThat(actualChatRequest -> {
assertThat(actualChatRequest.responseFormat()).isNull();
assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.TEXT).build());
return true;
})
);

12
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html

@ -91,9 +91,15 @@
{{ 'rule-node-config.ai.response-format' | translate }}
</div>
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="responseFormat.TEXT">{{ 'rule-node-config.ai.response-text' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="responseFormat.JSON">{{ 'rule-node-config.ai.response-json' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="responseFormat.JSON_SCHEMA">{{ 'rule-node-config.ai.response-json-schema' | translate }}</tb-toggle-option>
@if (allowedResponseFormats.includes(responseFormat.TEXT)) {
<tb-toggle-option [value]="responseFormat.TEXT">{{ 'rule-node-config.ai.response-text' | translate }}</tb-toggle-option>
}
@if (allowedResponseFormats.includes(responseFormat.JSON)) {
<tb-toggle-option [value]="responseFormat.JSON">{{ 'rule-node-config.ai.response-json' | translate }}</tb-toggle-option>
}
@if (allowedResponseFormats.includes(responseFormat.JSON_SCHEMA)) {
<tb-toggle-option [value]="responseFormat.JSON_SCHEMA">{{ 'rule-node-config.ai.response-json-schema' | translate }}</tb-toggle-option>
}
</tb-toggle-select>
</div>
<tb-json-object-edit

19
ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts

@ -20,7 +20,7 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m
import { EntityType } from '@shared/models/entity-type.models';
import { MatDialog } from '@angular/material/dialog';
import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component';
import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models';
import { AiModel, aiRuleNodeResponseFormats, ResponseFormat } from '@shared/models/ai-model.models';
import { deepTrim } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
import { jsonRequired } from '@shared/components/json-object-edit.component';
@ -41,6 +41,8 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
responseFormat = ResponseFormat;
allowedResponseFormats: ResponseFormat[] = [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA];
EntityType = EntityType;
ResourceType = ResourceType;
@ -93,15 +95,12 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent {
}
onEntityChange($event: AiModel) {
if ($event) {
if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) {
if (this.aiConfigForm.get('responseFormat.type').value !== ResponseFormat.TEXT) {
this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: true});
}
this.aiConfigForm.get('responseFormat.type').disable({emitEvent: false});
}
} else {
this.aiConfigForm.get('responseFormat.type').enable({emitEvent: false});
this.allowedResponseFormats = $event
? aiRuleNodeResponseFormats($event.configuration.provider)
: [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA];
const typeControl = this.aiConfigForm.get('responseFormat.type');
if (!this.allowedResponseFormats.includes(typeControl.value)) {
typeControl.patchValue(this.allowedResponseFormats[0], {emitEvent: true});
}
}

32
ui-ngx/src/app/shared/models/ai-model.models.ts

@ -105,11 +105,11 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
AiProvider.OPENAI,
{
modelList: [
'o4-mini',
'o3-pro',
'o3',
'o3-mini',
'o1',
'gpt-5.5-pro',
'gpt-5.5',
'gpt-5.4-pro',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.4-nano',
@ -120,7 +120,6 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
'gpt-5-nano',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-4.1-nano',
'gpt-4o',
'gpt-4o-mini',
],
@ -140,9 +139,10 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
AiProvider.GOOGLE_AI_GEMINI,
{
modelList: [
'gemini-3.5-flash',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
'gemini-3.1-flash-lite',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite'
@ -155,9 +155,10 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
AiProvider.GOOGLE_VERTEX_AI_GEMINI,
{
modelList: [
'gemini-3.5-flash',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
'gemini-3.1-flash-lite',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite'
@ -170,15 +171,12 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
AiProvider.MISTRAL_AI,
{
modelList: [
'magistral-medium-latest',
'magistral-small-latest',
'mistral-large-latest',
'mistral-medium-latest',
'mistral-small-latest',
'ministral-14b-latest',
'ministral-8b-latest',
'ministral-3b-latest',
'open-mistral-nemo',
'ministral-3b-latest'
],
providerFieldsList: ['apiKey'],
modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'],
@ -188,6 +186,8 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
AiProvider.ANTHROPIC,
{
modelList: [
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-opus-4-5',
'claude-opus-4-1',
@ -225,14 +225,22 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
],
]);
export const AiRuleNodeResponseFormatTypeOnlyText: AiProvider[] = [AiProvider.AMAZON_BEDROCK, AiProvider.ANTHROPIC, AiProvider.GITHUB_MODELS];
export enum ResponseFormat {
TEXT = 'TEXT',
JSON = 'JSON',
JSON_SCHEMA = 'JSON_SCHEMA'
}
export const aiRuleNodeResponseFormats = (provider: AiProvider): ResponseFormat[] => {
switch (provider) {
case AiProvider.ANTHROPIC:
case AiProvider.AMAZON_BEDROCK:
return [ResponseFormat.TEXT, ResponseFormat.JSON_SCHEMA];
default:
return [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA];
}
};
export interface AiModelWithUserMsg {
userMessage: {
contents: Array<{contentType: string; text: string}>;

6
ui-ngx/src/assets/locale/locale.constant-da_DK.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Sprog"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-de_DE.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Sprache"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-el_GR.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Γλώσσα"
}
}
}

4
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1107,8 +1107,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",

6
ui-ngx/src/assets/locale/locale.constant-es_ES.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Idioma"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-fr_FR.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Langue"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-it_IT.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Lingua"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-ja_JP.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "言語"
}
}
}

4
ui-ngx/src/assets/locale/locale.constant-lt_LT.json

@ -1106,8 +1106,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",

6
ui-ngx/src/assets/locale/locale.constant-nl_NL.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Taal"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-no_NO.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Språk"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-pt_BR.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Idioma"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-tr_TR.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Dil"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-uk_UA.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "Мова"
}
}
}

6
ui-ngx/src/assets/locale/locale.constant-zh_CN.json

@ -1097,8 +1097,8 @@
"ai-providers": {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"google-ai-gemini": "Google AI Gemini",
"google-vertex-ai-gemini": "Google Vertex AI Gemini",
"google-ai-gemini": "Google Gemini (Gemini API)",
"google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)",
"mistral-ai": "Mistral AI",
"anthropic": "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
@ -9538,4 +9538,4 @@
"language": {
"language": "语言"
}
}
}

Loading…
Cancel
Save