Browse Source

Merge pull request #14076 from dskarzh/feature/openai-base-url

AI models: add base URL to OpenAI
pull/14087/head
Viacheslav Klimov 1 year ago
committed by GitHub
parent
commit
15356225c3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  2. 12
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  3. 2
      application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java
  4. 6
      application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java
  5. 30
      common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java
  6. 5
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java
  7. 24
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
  8. 28
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts
  9. 2
      ui-ngx/src/app/shared/models/ai-model.models.ts
  10. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -70,6 +70,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey()) .apiKey(chatModelConfig.providerConfig().apiKey())
.modelName(chatModelConfig.modelId()) .modelName(chatModelConfig.modelId())
.temperature(chatModelConfig.temperature()) .temperature(chatModelConfig.temperature())

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

@ -104,7 +104,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class);
var newModelConfig = OpenAiChatModelConfig.builder() var newModelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key-updated")) .providerConfig(OpenAiProviderConfig.builder()
.baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
.apiKey("test-api-key-updated")
.build())
.modelId("o4-mini") .modelId("o4-mini")
.temperature(0.2) .temperature(0.2)
.topP(0.4) .topP(0.4)
@ -270,7 +273,7 @@ public class AiModelControllerTest extends AbstractControllerTest {
.tenantId(tenantId) .tenantId(tenantId)
.name("Test model 1") .name("Test model 1")
.configuration(OpenAiChatModelConfig.builder() .configuration(OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key")) .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("o3-pro") .modelId("o3-pro")
.build()) .build())
.build(), AiModel.class); .build(), AiModel.class);
@ -594,7 +597,10 @@ public class AiModelControllerTest extends AbstractControllerTest {
private AiModel constructValidOpenAiModel(String name) { private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder() var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key")) .providerConfig(OpenAiProviderConfig.builder()
.baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
.apiKey("test-api-key")
.build())
.modelId("gpt-4o") .modelId("gpt-4o")
.temperature(0.5) .temperature(0.5)
.topP(0.3) .topP(0.3)

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

@ -253,7 +253,7 @@ class DefaultTbAiModelServiceTest {
private static AiModelConfig constructValidOpenAiModelConfig() { private static AiModelConfig constructValidOpenAiModelConfig() {
return OpenAiChatModelConfig.builder() return OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key")) .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build())
.modelId("gpt-4o") .modelId("gpt-4o")
.temperature(0.5) .temperature(0.5)
.topP(0.3) .topP(0.3)

6
application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java

@ -682,7 +682,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
private AiModel constructValidOpenAiModel(String name) { private AiModel constructValidOpenAiModel(String name) {
var modelConfig = OpenAiChatModelConfig.builder() var modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key")) .providerConfig(OpenAiProviderConfig.builder()
.baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
.apiKey("test-api-key")
.build())
.modelId("gpt-4o") .modelId("gpt-4o")
.temperature(0.5) .temperature(0.5)
.topP(0.3) .topP(0.3)
@ -699,6 +702,7 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest {
.configuration(modelConfig) .configuration(modelConfig)
.build(); .build();
} }
@Test @Test
public void testFindTenantResourcesByTenantId() throws Exception { public void testFindTenantResourcesByTenantId() throws Exception {
loginSysAdmin(); loginSysAdmin();

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

@ -15,8 +15,32 @@
*/ */
package org.thingsboard.server.common.data.ai.provider; package org.thingsboard.server.common.data.ai.provider;
import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import lombok.Builder;
import org.apache.commons.lang3.StringUtils;
import java.util.Objects;
@Builder
public record OpenAiProviderConfig( public record OpenAiProviderConfig(
@NotNull String apiKey String baseUrl,
) implements AiProviderConfig {} String apiKey
) implements AiProviderConfig {
public static final String OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1";
public OpenAiProviderConfig {
baseUrl = Objects.requireNonNullElse(baseUrl, OPENAI_OFFICIAL_BASE_URL);
}
@JsonIgnore
@AssertTrue(message = "API key is required when using the official OpenAI API")
public boolean isValid() {
if (baseUrl.equals(OPENAI_OFFICIAL_BASE_URL)) {
return StringUtils.isNotBlank(apiKey);
}
return true;
}
}

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

@ -129,7 +129,10 @@ class TbAiNodeTest {
config = new TbAiNodeConfiguration(); config = new TbAiNodeConfiguration();
modelConfig = OpenAiChatModelConfig.builder() modelConfig = OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig("test-api-key")) .providerConfig(OpenAiProviderConfig.builder()
.baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL)
.apiKey("test-api-key")
.build())
.modelId("gpt-4o") .modelId("gpt-4o")
.temperature(0.5) .temperature(0.5)
.topP(0.3) .topP(0.3)

24
ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html

@ -116,14 +116,24 @@
<input matInput formControlName="serviceVersion"> <input matInput formControlName="serviceVersion">
</mat-form-field> </mat-form-field>
} }
@if (providerFieldsList.includes('baseUrl')) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.baseurl</mat-label>
<input required matInput formControlName="baseUrl">
<mat-error *ngIf="aiModelForms.get('configuration.providerConfig.baseUrl').hasError('required') ||
aiModelForms.get('configuration.providerConfig.baseUrl').hasError('pattern')">
{{ 'ai-models.baseurl-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (providerFieldsList.includes('apiKey')) { @if (providerFieldsList.includes('apiKey')) {
<mat-form-field class="mat-block flex-1" appearance="outline"> <mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.api-key</mat-label> <mat-label translate>ai-models.api-key</mat-label>
<input type="password" required matInput formControlName="apiKey" autocomplete="new-password"> <input type="password" matInput formControlName="apiKey" [required]="apiKeyRequired" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password> <tb-toggle-password matSuffix></tb-toggle-password>
<mat-error *ngIf="aiModelForms.get('configuration.providerConfig.apiKey').hasError('required') || <mat-error *ngIf="aiModelForms.get('configuration.providerConfig.apiKey').hasError('required') ||
aiModelForms.get('configuration.providerConfig.apiKey').hasError('pattern')"> aiModelForms.get('configuration.providerConfig.apiKey').hasError('pattern')">
{{ 'ai-models.api-key-required' | translate }} {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
} }
@ -158,16 +168,6 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
} }
@if (providerFieldsList.includes('baseUrl')) {
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>ai-models.baseurl</mat-label>
<input required matInput formControlName="baseUrl">
<mat-error *ngIf="aiModelForms.get('configuration.providerConfig.baseUrl').hasError('required') ||
aiModelForms.get('configuration.providerConfig.baseUrl').hasError('pattern')">
{{ 'ai-models.baseurl-required' | translate }}
</mat-error>
</mat-form-field>
}
@if (provider === aiProvider.OLLAMA) { @if (provider === aiProvider.OLLAMA) {
<div class="tb-form-panel stroked no-gap no-padding-bottom mb-4" formGroupName="auth"> <div class="tb-form-panel stroked no-gap no-padding-bottom mb-4" formGroupName="auth">
<div class="flex flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3"> <div class="flex flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">

28
ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts

@ -74,6 +74,9 @@ export class AIModelDialogComponent extends DialogComponent<AIModelDialogCompone
authenticationHint: string; authenticationHint: string;
apiKeyRequired = true;
private readonly openAiDefaultBaseUrl = 'https://api.openai.com/v1';
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
protected dialogRef: MatDialogRef<AIModelDialogComponent, AiModel>, protected dialogRef: MatDialogRef<AIModelDialogComponent, AiModel>,
@ -107,7 +110,7 @@ export class AIModelDialogComponent extends DialogComponent<AIModelDialogCompone
region: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.region : '', [Validators.required, Validators.pattern(/.*\S.*/)]], region: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.region : '', [Validators.required, Validators.pattern(/.*\S.*/)]],
accessKeyId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.accessKeyId : '', [Validators.required, Validators.pattern(/.*\S.*/)]], accessKeyId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.accessKeyId : '', [Validators.required, Validators.pattern(/.*\S.*/)]],
secretAccessKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.secretAccessKey : '', [Validators.required, Validators.pattern(/.*\S.*/)]], secretAccessKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.secretAccessKey : '', [Validators.required, Validators.pattern(/.*\S.*/)]],
baseUrl: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.baseUrl : '', [Validators.required, Validators.pattern(/.*\S.*/)]], baseUrl: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.baseUrl : this.openAiDefaultBaseUrl, [Validators.required, Validators.pattern(/.*\S.*/)]],
auth: this.fb.group({ auth: this.fb.group({
type: [this.data.AIModel?.configuration?.providerConfig?.auth?.type ?? AuthenticationType.NONE], type: [this.data.AIModel?.configuration?.providerConfig?.auth?.type ?? AuthenticationType.NONE],
username: [this.data.AIModel?.configuration?.providerConfig?.auth?.username ?? '', [Validators.required, Validators.pattern(/.*\S.*/)]], username: [this.data.AIModel?.configuration?.providerConfig?.auth?.username ?? '', [Validators.required, Validators.pattern(/.*\S.*/)]],
@ -133,6 +136,18 @@ export class AIModelDialogComponent extends DialogComponent<AIModelDialogCompone
this.aiModelForms.get('configuration.modelId').reset(''); this.aiModelForms.get('configuration.modelId').reset('');
this.aiModelForms.get('configuration.providerConfig').reset({}); this.aiModelForms.get('configuration.providerConfig').reset({});
this.updateValidation(provider); this.updateValidation(provider);
if (provider === AiProvider.OPENAI) {
this.aiModelForms.get('configuration.providerConfig.baseUrl').patchValue(this.openAiDefaultBaseUrl, {emitEvent: false});
this.updateApiKeyValidatorForOpenAIProvider(this.openAiDefaultBaseUrl);
}
});
this.aiModelForms.get('configuration.providerConfig.baseUrl').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((url: string) => {
if (this.provider === AiProvider.OPENAI) {
this.updateApiKeyValidatorForOpenAIProvider(url);
}
}); });
this.aiModelForms.get('configuration.providerConfig.auth.type').valueChanges.pipe( this.aiModelForms.get('configuration.providerConfig.auth.type').valueChanges.pipe(
@ -161,6 +176,17 @@ export class AIModelDialogComponent extends DialogComponent<AIModelDialogCompone
); );
} }
private updateApiKeyValidatorForOpenAIProvider(url: string) {
if (url !== this.openAiDefaultBaseUrl) {
this.aiModelForms.get('configuration.providerConfig.apiKey').removeValidators(Validators.required);
this.apiKeyRequired = false;
} else {
this.aiModelForms.get('configuration.providerConfig.apiKey').addValidators(Validators.required);
this.apiKeyRequired = true;
}
this.aiModelForms.get('configuration.providerConfig.apiKey').updateValueAndValidity({emitEvent: false});
}
private getAuthenticationHint(type: AuthenticationType) { private getAuthenticationHint(type: AuthenticationType) {
if (type === AuthenticationType.BASIC) { if (type === AuthenticationType.BASIC) {
this.authenticationHint = this.translate.instant('ai-models.authentication-basic-hint'); this.authenticationHint = this.translate.instant('ai-models.authentication-basic-hint');

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

@ -116,7 +116,7 @@ export const AiModelMap = new Map<AiProvider, { modelList: string[], providerFie
'gpt-4o', 'gpt-4o',
'gpt-4o-mini', 'gpt-4o-mini',
], ],
providerFieldsList: ['apiKey'], providerFieldsList: ['baseUrl', 'apiKey'],
modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'],
}, },
], ],

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

@ -1120,6 +1120,7 @@
"provider": "Provider", "provider": "Provider",
"api-key": "API key", "api-key": "API key",
"api-key-required": "API key is required.", "api-key-required": "API key is required.",
"api-key-open-ai-required": "API key is required when using the official OpenAI API.",
"project-id": "Project ID", "project-id": "Project ID",
"project-id-required": "Project ID is required", "project-id-required": "Project ID is required",
"location": "Location", "location": "Location",

Loading…
Cancel
Save