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