Merge pull request #14076 from dskarzh/feature/openai-base-url
AI models: add base URL to OpenAI
This commit is contained in:
commit
15356225c3
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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…
x
Reference in New Issue
Block a user