This is an automated email from the ASF dual-hosted git repository.
wyxxxcat pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new bc9686578a5 [Fix](ai) Fix some AI Resours Issues (#68023)
bc9686578a5 is described below
commit bc9686578a5a3675b8d04b0a0b4fc9ff6a983d96
Author: linrrarity <[email protected]>
AuthorDate: Thu Nov 5 17:11:40 2026 +0800
[Fix](ai) Fix some AI Resours Issues (#68023)
### What problem does this PR solve?
Issue Number: close #xxx
Related PR: #xxx
Problem Summary:
- **Centralize property handling:** Move binding, defaults, validation,
and Thrift conversion into `AIResourceProperties`, reusing the existing
`ConnectorProperty` framework. Keep `AIProperties` as constants.
- **Strengthen validation:** Reject blank values and invalid numeric
ranges, and apply standard string trimming.
- **Ensure ALTER consistency:** Validate the complete configuration
before replacement and safely publish updates to concurrent readers.
- **Preserve metadata compatibility:** Keep the persisted map format and
defer validation of loaded metadata until use.
- **Correct resource usage:** Update AI function resource checks and add
regression coverage.
---
.../java/org/apache/doris/catalog/AIResource.java | 126 +----
.../datasource/property/AIResourceProperties.java | 295 ++++++++++++
.../property/constants/AIProperties.java | 105 -----
.../nereids/rules/analysis/ExpressionAnalyzer.java | 18 +-
.../trees/expressions/functions/agg/AIAgg.java | 7 +
.../trees/expressions/functions/ai/AIFunction.java | 16 +
.../trees/expressions/functions/ai/Embed.java | 1 +
.../doris/catalog/AIResourceConcurrencyTest.java | 134 ++++++
.../doris/catalog/AIResourcePropertiesTest.java | 516 +++++++++++++++++++++
.../org/apache/doris/catalog/AIResourceTest.java | 251 +++++++---
.../data/ai_p0/test_create_ai_resource.out | 37 ++
.../suites/ai_p0/test_ai_resource_privilege.groovy | 114 +++++
.../suites/ai_p0/test_create_ai_resource.groovy | 109 ++++-
13 files changed, 1445 insertions(+), 284 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/AIResource.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/AIResource.java
index 1709236b2a0..c18e9e9a365 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/AIResource.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/AIResource.java
@@ -19,18 +19,17 @@ package org.apache.doris.catalog;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.proc.BaseProcResult;
+import org.apache.doris.datasource.property.AIResourceProperties;
import org.apache.doris.datasource.property.constants.AIProperties;
import org.apache.doris.thrift.TAIResource;
import com.google.common.base.Preconditions;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.gson.annotations.SerializedName;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
/**
@@ -55,7 +54,7 @@ import java.util.Map;
public class AIResource extends Resource {
@SerializedName(value = "properties")
- private Map<String, String> properties;
+ private volatile Map<String, String> properties;
@SerializedName(value = "createdByRoot")
private boolean createdByRoot;
@@ -79,10 +78,7 @@ public class AIResource extends Resource {
@Override
protected void setProperties(ImmutableMap<String, String> newProperties)
throws DdlException {
Preconditions.checkState(newProperties != null);
- this.properties = Maps.newHashMap(newProperties);
-
- AIProperties.requiredAIProperties(properties);
- AIProperties.optionalAIProperties(this.properties);
+ this.properties = parseProperties(newProperties).toMap();
}
public String getProperty(String propertyKey) {
@@ -90,28 +86,15 @@ public class AIResource extends Resource {
}
public boolean hasCompleteGeneralProperties() {
- return AIProperties.REQUIRED_FIELDS.stream()
- .allMatch(field ->
!Strings.isNullOrEmpty(properties.get(field)))
- &&
("LOCAL".equalsIgnoreCase(properties.get(AIProperties.PROVIDER_TYPE))
- ||
!Strings.isNullOrEmpty(properties.get(AIProperties.API_KEY)));
+ return AIResourceProperties.hasCompleteGeneralProperties(properties);
}
public boolean hasCompleteEmbedProperties() {
- return hasCompleteProperties(AIProperties.EMBED_REQUIRED_FIELDS,
- AIProperties.EMBED_PROVIDER_TYPE, AIProperties.EMBED_API_KEY);
+ return AIResourceProperties.hasCompleteEmbedProperties(properties);
}
public boolean hasCompleteMultimodalEmbedProperties() {
- return
hasCompleteProperties(AIProperties.MULTIMODAL_EMBED_REQUIRED_FIELDS,
- AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE,
AIProperties.MULTIMODAL_EMBED_API_KEY);
- }
-
- private boolean hasCompleteProperties(List<String> requiredFields,
- String providerTypeField, String apiKeyField) {
- return requiredFields.stream()
- .allMatch(field ->
!Strings.isNullOrEmpty(properties.get(field)))
- && ("LOCAL".equalsIgnoreCase(properties.get(providerTypeField))
- ||
!Strings.isNullOrEmpty(properties.get(apiKeyField)));
+ return
AIResourceProperties.hasCompleteMultimodalEmbedProperties(properties);
}
@Override
@@ -119,20 +102,8 @@ public class AIResource extends Resource {
writeLock();
try {
Map<String, String> changedProperties = new
HashMap<>(this.properties);
- for (Map.Entry<String, String> kv : properties.entrySet()) {
- replaceIfEffectiveValue(changedProperties, kv.getKey(),
kv.getValue());
- if (kv.getKey().equals(AIProperties.API_KEY)
- || kv.getKey().equals(AIProperties.EMBED_API_KEY)
- ||
kv.getKey().equals(AIProperties.MULTIMODAL_EMBED_API_KEY)) {
- changedProperties.put(kv.getKey(), kv.getValue());
- } else if (kv.getKey().equals(AIProperties.EFFORT)
- && Strings.isNullOrEmpty(kv.getValue())) {
- changedProperties.remove(kv.getKey());
- }
- }
- AIProperties.requiredAIProperties(changedProperties);
-
- this.properties = changedProperties;
+ changedProperties.putAll(properties);
+ this.properties = parseProperties(changedProperties).toMap();
++version;
} finally {
writeUnlock();
@@ -163,82 +134,15 @@ public class AIResource extends Resource {
readUnlock();
}
- public TAIResource toThrift() throws NumberFormatException {
- TAIResource tAIResource = new TAIResource();
-
tAIResource.setEnableStructuredOutput(Boolean.parseBoolean(properties.getOrDefault(
- AIProperties.ENABLE_STRUCTURED_OUTPUT,
AIProperties.DEFAULT_ENABLE_STRUCTURED_OUTPUT)));
- if (properties.containsKey(AIProperties.PROVIDER_TYPE)) {
-
tAIResource.setProviderType(properties.get(AIProperties.PROVIDER_TYPE));
- }
- if (properties.containsKey(AIProperties.ENDPOINT)) {
- tAIResource.setEndpoint(properties.get(AIProperties.ENDPOINT));
- }
- if (properties.containsKey(AIProperties.API_KEY)) {
- tAIResource.setApiKey(properties.get(AIProperties.API_KEY));
- }
- if (properties.containsKey(AIProperties.MODEL_NAME)) {
- tAIResource.setModelName(properties.get(AIProperties.MODEL_NAME));
- }
- if (properties.containsKey(AIProperties.EMBED_PROVIDER_TYPE)) {
-
tAIResource.setEmbedProviderType(properties.get(AIProperties.EMBED_PROVIDER_TYPE));
- }
- if (properties.containsKey(AIProperties.EMBED_ENDPOINT)) {
-
tAIResource.setEmbedEndpoint(properties.get(AIProperties.EMBED_ENDPOINT));
- }
- if (properties.containsKey(AIProperties.EMBED_API_KEY)) {
-
tAIResource.setEmbedApiKey(properties.get(AIProperties.EMBED_API_KEY));
- }
- if (properties.containsKey(AIProperties.EMBED_MODEL_NAME)) {
-
tAIResource.setEmbedModelName(properties.get(AIProperties.EMBED_MODEL_NAME));
- }
- if
(properties.containsKey(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE)) {
-
tAIResource.setEmbedMmProviderType(properties.get(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE));
- }
- if (properties.containsKey(AIProperties.MULTIMODAL_EMBED_ENDPOINT)) {
-
tAIResource.setEmbedMmEndpoint(properties.get(AIProperties.MULTIMODAL_EMBED_ENDPOINT));
- }
- if (properties.containsKey(AIProperties.MULTIMODAL_EMBED_API_KEY)) {
-
tAIResource.setEmbedMmApiKey(properties.get(AIProperties.MULTIMODAL_EMBED_API_KEY));
- }
- if (properties.containsKey(AIProperties.MULTIMODAL_EMBED_MODEL_NAME)) {
-
tAIResource.setEmbedMmModelName(properties.get(AIProperties.MULTIMODAL_EMBED_MODEL_NAME));
- }
- if (!Strings.isNullOrEmpty(properties.get(AIProperties.EFFORT))) {
- tAIResource.setEffort(properties.get(AIProperties.EFFORT));
- }
-
tAIResource.setAnthropicVersion(properties.get(AIProperties.ANTHROPIC_VERSION));
+ public TAIResource toThrift() {
+ return AIResourceProperties.of(properties).toThrift();
+ }
+ private static AIResourceProperties parseProperties(Map<String, String>
properties) throws DdlException {
try {
-
tAIResource.setTemperature(Double.parseDouble(properties.get(AIProperties.TEMPERATURE)));
- } catch (NumberFormatException e) {
- throw new NumberFormatException("Failed to parse temperature: "
- +
properties.get(AIProperties.TEMPERATURE));
- }
- try {
-
tAIResource.setMaxTokens(Long.parseLong(properties.get(AIProperties.MAX_TOKEN)));
- } catch (NumberFormatException e) {
- throw new NumberFormatException("Failed to parse max_token: "
- +
properties.get(AIProperties.MAX_TOKEN));
+ return AIResourceProperties.of(properties);
+ } catch (IllegalArgumentException e) {
+ throw new DdlException(e.getMessage(), e);
}
- try {
-
tAIResource.setMaxRetries(Integer.parseInt(properties.get(AIProperties.MAX_RETRIES)));
- } catch (NumberFormatException e) {
- throw new NumberFormatException("Failed to parse max_retries: "
- +
properties.get(AIProperties.MAX_RETRIES));
- }
- try {
-
tAIResource.setRetryDelaySecond(Integer.parseInt(properties.get(AIProperties.RETRY_DELAY_SECOND)));
- } catch (NumberFormatException e) {
- throw new NumberFormatException("Failed to parse
retry_delay_second: "
- +
properties.get(AIProperties.RETRY_DELAY_SECOND));
- }
- try {
-
tAIResource.setDimensions(Integer.parseInt(properties.get(AIProperties.DIMENSIONS)));
- } catch (NumberFormatException e) {
- throw new NumberFormatException("Failed to parse dimensions: "
- +
properties.get(AIProperties.DIMENSIONS));
- }
-
- return tAIResource;
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/AIResourceProperties.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/AIResourceProperties.java
new file mode 100644
index 00000000000..83776af54c5
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/AIResourceProperties.java
@@ -0,0 +1,295 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.property;
+
+import org.apache.doris.datasource.property.constants.AIProperties;
+import org.apache.doris.foundation.property.ConnectorPropertiesUtils;
+import org.apache.doris.foundation.property.ConnectorProperty;
+import org.apache.doris.foundation.property.ConnectorPropertyValidator;
+import org.apache.doris.foundation.property.ParamRules;
+import org.apache.doris.thrift.TAIResource;
+
+import com.google.common.base.Strings;
+import org.apache.commons.lang3.StringUtils;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public final class AIResourceProperties {
+ private static final List<String> ALL_EFFORT_LEVELS =
+ Arrays.asList("none", "minimal", "low", "medium", "high", "xhigh",
"max");
+ private static final List<String> ANTHROPIC_EFFORT_LEVELS =
+ Arrays.asList("low", "medium", "high", "xhigh", "max");
+ private static final List<String> GEMINI_EFFORT_LEVELS =
+ Arrays.asList("minimal", "low", "medium", "high");
+
+ @ConnectorProperty(names = {AIProperties.ENDPOINT}, required = false)
+ private String endpoint;
+ @ConnectorProperty(names = {AIProperties.PROVIDER_TYPE}, required = false)
+ private String providerType;
+ @ConnectorProperty(names = {AIProperties.MODEL_NAME}, required = false)
+ private String modelName;
+ @ConnectorProperty(names = {AIProperties.API_KEY}, required = false,
sensitive = true)
+ private String apiKey;
+ @ConnectorProperty(names = {AIProperties.EMBED_ENDPOINT}, required = false)
+ private String embedEndpoint;
+ @ConnectorProperty(names = {AIProperties.EMBED_PROVIDER_TYPE}, required =
false)
+ private String embedProviderType;
+ @ConnectorProperty(names = {AIProperties.EMBED_MODEL_NAME}, required =
false)
+ private String embedModelName;
+ @ConnectorProperty(names = {AIProperties.EMBED_API_KEY}, required = false,
sensitive = true)
+ private String embedApiKey;
+ @ConnectorProperty(names = {AIProperties.MULTIMODAL_EMBED_ENDPOINT},
required = false)
+ private String multimodalEmbedEndpoint;
+ @ConnectorProperty(names = {AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE},
required = false)
+ private String multimodalEmbedProviderType;
+ @ConnectorProperty(names = {AIProperties.MULTIMODAL_EMBED_MODEL_NAME},
required = false)
+ private String multimodalEmbedModelName;
+ @ConnectorProperty(names = {AIProperties.MULTIMODAL_EMBED_API_KEY},
required = false, sensitive = true)
+ private String multimodalEmbedApiKey;
+ @ConnectorProperty(names = {AIProperties.TEMPERATURE}, required = false,
validator = TemperatureValidator.class)
+ private double temperature;
+ @ConnectorProperty(names = {AIProperties.MAX_TOKEN}, required = false,
validator = MaxTokenValidator.class)
+ private long maxTokens;
+ @ConnectorProperty(names = {AIProperties.MAX_RETRIES}, required = false,
+ validator = NonNegativeIntegerValidator.class)
+ private int maxRetries;
+ @ConnectorProperty(names = {AIProperties.RETRY_DELAY_SECOND}, required =
false,
+ validator = NonNegativeIntegerValidator.class)
+ private int retryDelaySecond;
+ @ConnectorProperty(names = {AIProperties.DIMENSIONS}, required = false,
validator = DimensionsValidator.class)
+ private int dimensions;
+ @ConnectorProperty(names = {AIProperties.ANTHROPIC_VERSION}, required =
false)
+ private String anthropicVersion;
+ @ConnectorProperty(names = {AIProperties.EFFORT}, required = false)
+ private String effort;
+ @ConnectorProperty(names = {AIProperties.ENABLE_STRUCTURED_OUTPUT},
required = false,
+ validator = StructuredOutputValidator.class)
+ private boolean enableStructuredOutput;
+
+ private final Map<String, String> properties;
+
+ private AIResourceProperties(Map<String, String> properties) {
+ this.properties = new HashMap<>(properties);
+ this.properties.putIfAbsent(AIProperties.ENABLE_STRUCTURED_OUTPUT,
+ AIProperties.DEFAULT_ENABLE_STRUCTURED_OUTPUT);
+ this.properties.putIfAbsent(AIProperties.TEMPERATURE,
AIProperties.DEFAULT_TEMPERATURE);
+ this.properties.putIfAbsent(AIProperties.MAX_TOKEN,
AIProperties.DEFAULT_MAX_TOKEN);
+ this.properties.putIfAbsent(AIProperties.MAX_RETRIES,
AIProperties.DEFAULT_MAX_RETRIES);
+ this.properties.putIfAbsent(AIProperties.RETRY_DELAY_SECOND,
AIProperties.DEFAULT_RETRY_DELAY_SECOND);
+ this.properties.putIfAbsent(AIProperties.DIMENSIONS,
AIProperties.DEFAULT_DIMENSIONS);
+ this.properties.putIfAbsent(AIProperties.ANTHROPIC_VERSION,
AIProperties.DEFAULT_ANTHROPIC_VERSION);
+ }
+
+ public static AIResourceProperties of(Map<String, String> properties) {
+ ParamRules rules = new ParamRules();
+ properties.forEach((key, value) -> rules.require(value, "[" + key + "]
must not be empty"));
+ rules.validate();
+ AIResourceProperties result = new AIResourceProperties(properties);
+ ConnectorPropertiesUtils.bindConnectorProperties(result,
result.properties);
+ result.providerType =
result.normalizeProvider(AIProperties.PROVIDER_TYPE, result.providerType);
+ result.embedProviderType =
result.normalizeProvider(AIProperties.EMBED_PROVIDER_TYPE,
result.embedProviderType);
+ result.multimodalEmbedProviderType = result.normalizeProvider(
+ AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE,
result.multimodalEmbedProviderType);
+ result.validate();
+ return result;
+ }
+
+ private String normalizeProvider(String key, String value) {
+ if (value == null) {
+ return null;
+ }
+ String normalized = value.toUpperCase(Locale.ROOT);
+ properties.put(key, normalized);
+ return normalized;
+ }
+
+ private void validate() {
+ boolean hasGeneral = hasAnyProperty(AIProperties.REQUIRED_FIELDS,
AIProperties.API_KEY);
+ boolean hasEmbed = hasAnyProperty(AIProperties.EMBED_REQUIRED_FIELDS,
AIProperties.EMBED_API_KEY);
+ boolean hasMultimodal = hasAnyProperty(
+ AIProperties.MULTIMODAL_EMBED_REQUIRED_FIELDS,
AIProperties.MULTIMODAL_EMBED_API_KEY);
+ new ParamRules().check(() -> !hasGeneral && !hasEmbed &&
!hasMultimodal,
+ "At least one complete AI property group must be
configured.").validate();
+ if (hasGeneral) {
+ validateGroup(AIProperties.REQUIRED_FIELDS, providerType,
AIProperties.API_KEY, apiKey);
+ }
+ if (hasEmbed) {
+ validateGroup(AIProperties.EMBED_REQUIRED_FIELDS,
embedProviderType,
+ AIProperties.EMBED_API_KEY, embedApiKey);
+ }
+ if (hasMultimodal) {
+ validateGroup(AIProperties.MULTIMODAL_EMBED_REQUIRED_FIELDS,
multimodalEmbedProviderType,
+ AIProperties.MULTIMODAL_EMBED_API_KEY,
multimodalEmbedApiKey);
+ }
+ List<String> effortLevels = "ANTHROPIC".equals(providerType) ?
ANTHROPIC_EFFORT_LEVELS
+ : "GEMINI".equals(providerType) ? GEMINI_EFFORT_LEVELS :
ALL_EFFORT_LEVELS;
+ new ParamRules().check(() -> !Strings.isNullOrEmpty(effort) &&
!effortLevels.contains(effort),
+ "[" + AIProperties.EFFORT + "] must be one of " + effortLevels
+ + " for provider: " + providerType).validate();
+ }
+
+ private boolean hasAnyProperty(List<String> requiredFields, String
apiKeyField) {
+ return properties.containsKey(apiKeyField) ||
requiredFields.stream().anyMatch(properties::containsKey);
+ }
+
+ private void validateGroup(List<String> requiredFields, String provider,
String apiKeyField, String key) {
+ ParamRules rules = new ParamRules();
+ for (String field : requiredFields) {
+ rules.check(() -> Strings.isNullOrEmpty(properties.get(field)),
+ "Missing [" + field + "] in properties.");
+ }
+ rules.check(() -> !AIProperties.PROVIDERS.contains(provider),
+ "Provider must be one of " + AIProperties.PROVIDERS);
+ if (!"LOCAL".equals(provider)) {
+ rules.check(() -> Strings.isNullOrEmpty(key),
+ "Missing [" + apiKeyField + "] in properties for provider:
" + provider);
+ }
+ rules.validate();
+ }
+
+ public boolean hasCompleteGeneralProperties() {
+ return hasCompleteGeneralProperties(properties);
+ }
+
+ public boolean hasCompleteEmbedProperties() {
+ return hasCompleteEmbedProperties(properties);
+ }
+
+ public boolean hasCompleteMultimodalEmbedProperties() {
+ return hasCompleteMultimodalEmbedProperties(properties);
+ }
+
+ public static boolean hasCompleteGeneralProperties(Map<String, String>
properties) {
+ return hasCompleteGroup(properties, AIProperties.REQUIRED_FIELDS,
+ AIProperties.PROVIDER_TYPE, AIProperties.API_KEY);
+ }
+
+ public static boolean hasCompleteEmbedProperties(Map<String, String>
properties) {
+ return hasCompleteGroup(properties, AIProperties.EMBED_REQUIRED_FIELDS,
+ AIProperties.EMBED_PROVIDER_TYPE, AIProperties.EMBED_API_KEY);
+ }
+
+ public static boolean hasCompleteMultimodalEmbedProperties(Map<String,
String> properties) {
+ return hasCompleteGroup(properties,
AIProperties.MULTIMODAL_EMBED_REQUIRED_FIELDS,
+ AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE,
AIProperties.MULTIMODAL_EMBED_API_KEY);
+ }
+
+ private static boolean hasCompleteGroup(Map<String, String> properties,
List<String> requiredFields,
+ String providerField, String apiKeyField) {
+ return requiredFields.stream()
+ .allMatch(field ->
StringUtils.isNotEmpty(StringUtils.trim(properties.get(field))))
+ &&
("LOCAL".equalsIgnoreCase(StringUtils.trim(properties.get(providerField)))
+ ||
StringUtils.isNotEmpty(StringUtils.trim(properties.get(apiKeyField))));
+ }
+
+ public Map<String, String> toMap() {
+ Map<String, String> normalized = new HashMap<>(properties);
+ for (Field field :
ConnectorPropertiesUtils.getConnectorProperties(AIResourceProperties.class)) {
+ for (String name :
field.getAnnotation(ConnectorProperty.class).names()) {
+ normalized.computeIfPresent(name, (key, value) ->
value.trim());
+ }
+ }
+ return normalized;
+ }
+
+ public TAIResource toThrift() {
+ TAIResource result = new TAIResource();
+ result.setEnableStructuredOutput(enableStructuredOutput);
+ result.setEndpoint(endpoint);
+ result.setProviderType(providerType);
+ result.setModelName(modelName);
+ result.setApiKey(apiKey);
+ result.setEmbedEndpoint(embedEndpoint);
+ result.setEmbedProviderType(embedProviderType);
+ result.setEmbedModelName(embedModelName);
+ result.setEmbedApiKey(embedApiKey);
+ result.setEmbedMmEndpoint(multimodalEmbedEndpoint);
+ result.setEmbedMmProviderType(multimodalEmbedProviderType);
+ result.setEmbedMmModelName(multimodalEmbedModelName);
+ result.setEmbedMmApiKey(multimodalEmbedApiKey);
+ result.setTemperature(temperature);
+ result.setMaxTokens(maxTokens);
+ result.setMaxRetries(maxRetries);
+ result.setRetryDelaySecond(retryDelaySecond);
+ result.setDimensions(dimensions);
+ result.setAnthropicVersion(anthropicVersion);
+ if (!Strings.isNullOrEmpty(effort)) {
+ result.setEffort(effort);
+ }
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return ConnectorPropertiesUtils.toMaskedString(this);
+ }
+
+ public static final class StructuredOutputValidator implements
ConnectorPropertyValidator {
+ @Override
+ public void validate(Object target, Field field, String propertyName,
Object value,
+ Map<String, String> properties) {
+ String text = properties.get(propertyName).trim();
+ new ParamRules().check(() -> !"true".equalsIgnoreCase(text) &&
!"false".equalsIgnoreCase(text),
+ "[" + propertyName + "] must be true or false").validate();
+ }
+ }
+
+ public static final class TemperatureValidator implements
ConnectorPropertyValidator {
+ @Override
+ public void validate(Object target, Field field, String propertyName,
Object value,
+ Map<String, String> properties) {
+ double temperature = (Double) value;
+ new ParamRules().check(() -> temperature != -1 && !(temperature >=
0 && temperature <= 1),
+ "Temperature must be -1 or a double between 0 and
1").validate();
+ }
+ }
+
+ public static final class MaxTokenValidator implements
ConnectorPropertyValidator {
+ @Override
+ public void validate(Object target, Field field, String propertyName,
Object value,
+ Map<String, String> properties) {
+ long maxTokens = (Long) value;
+ new ParamRules().check(() -> maxTokens != -1 && maxTokens <= 0,
+ "Max token must be a positive integer or -1").validate();
+ }
+ }
+
+ public static final class NonNegativeIntegerValidator implements
ConnectorPropertyValidator {
+ @Override
+ public void validate(Object target, Field field, String propertyName,
Object value,
+ Map<String, String> properties) {
+ new ParamRules().check(() -> (Integer) value < 0,
+ "[" + propertyName + "] must be a non-negative
integer").validate();
+ }
+ }
+
+ public static final class DimensionsValidator implements
ConnectorPropertyValidator {
+ @Override
+ public void validate(Object target, Field field, String propertyName,
Object value,
+ Map<String, String> properties) {
+ int dimensions = (Integer) value;
+ new ParamRules().check(() -> dimensions != -1 && dimensions <= 0,
+ "Dimensions must be a positive integer or -1").validate();
+ }
+ }
+}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
index e8c6a35d692..7aa4983f728 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
@@ -17,13 +17,8 @@
package org.apache.doris.datasource.property.constants;
-import org.apache.doris.common.DdlException;
-
-import com.google.common.base.Strings;
-
import java.util.Arrays;
import java.util.List;
-import java.util.Map;
public class AIProperties extends BaseProperties {
public static final String AI_PREFIX = "ai.";
@@ -73,104 +68,4 @@ public class AIProperties extends BaseProperties {
public static final List<String> PROVIDERS
= Arrays.asList("OPENAI", "LOCAL", "GEMINI", "DEEPSEEK",
"ANTHROPIC",
"MOONSHOT", "QWEN", "MINIMAX", "ZHIPU", "BAICHUAN", "VOYAGEAI",
"JINA");
- private static final List<String> ALL_EFFORT_LEVELS =
- Arrays.asList("none", "minimal", "low", "medium", "high", "xhigh",
"max");
- private static final List<String> ANTHROPIC_EFFORT_LEVELS =
- Arrays.asList("low", "medium", "high", "xhigh", "max");
- private static final List<String> GEMINI_EFFORT_LEVELS =
- Arrays.asList("minimal", "low", "medium", "high");
-
- public static void requiredAIProperties(Map<String, String> properties)
throws DdlException {
- boolean hasGeneralProperties = hasAnyProperty(properties,
REQUIRED_FIELDS, API_KEY);
- boolean hasEmbedProperties = hasAnyProperty(properties,
EMBED_REQUIRED_FIELDS, EMBED_API_KEY);
- boolean hasMultimodalEmbedProperties = hasAnyProperty(properties,
- MULTIMODAL_EMBED_REQUIRED_FIELDS, MULTIMODAL_EMBED_API_KEY);
- if (!hasGeneralProperties && !hasEmbedProperties &&
!hasMultimodalEmbedProperties) {
- throw new DdlException("At least one complete AI property group
must be configured.");
- }
-
- if (hasGeneralProperties) {
- validatePropertyGroup(properties, REQUIRED_FIELDS, PROVIDER_TYPE,
API_KEY);
- }
- if (hasEmbedProperties) {
- validatePropertyGroup(properties, EMBED_REQUIRED_FIELDS,
EMBED_PROVIDER_TYPE, EMBED_API_KEY);
- }
- if (hasMultimodalEmbedProperties) {
- validatePropertyGroup(properties, MULTIMODAL_EMBED_REQUIRED_FIELDS,
- MULTIMODAL_EMBED_PROVIDER_TYPE, MULTIMODAL_EMBED_API_KEY);
- }
-
- String enableStructuredOutput =
properties.get(ENABLE_STRUCTURED_OUTPUT);
- if (enableStructuredOutput != null &&
!"true".equalsIgnoreCase(enableStructuredOutput)
- && !"false".equalsIgnoreCase(enableStructuredOutput)) {
- throw new DdlException("[" + ENABLE_STRUCTURED_OUTPUT + "] must be
true or false");
- }
-
- String effort = properties.get(EFFORT);
- if (!Strings.isNullOrEmpty(effort)) {
- String providerType = properties.get(PROVIDER_TYPE);
- List<String> effortLevels = ALL_EFFORT_LEVELS;
- if ("ANTHROPIC".equals(providerType)) {
- effortLevels = ANTHROPIC_EFFORT_LEVELS;
- } else if ("GEMINI".equals(providerType)) {
- effortLevels = GEMINI_EFFORT_LEVELS;
- }
- if (!effortLevels.contains(effort)) {
- throw new DdlException("[" + EFFORT + "] must be one of " +
effortLevels
- + " for provider: " + providerType);
- }
- }
-
- // Check weather the 'temperature' is valid
- String temp = properties.get(AIProperties.TEMPERATURE);
- if (!Strings.isNullOrEmpty(temp) && !temp.equals("-1")) {
- double tempVal = Double.parseDouble(temp);
- if (!(tempVal >= 0 && tempVal <= 1)) {
- throw new DdlException("Temperature must be a double between 0
and 1");
- }
- }
-
- // Check 'dimensions'
- temp = properties.get(AIProperties.DIMENSIONS);
- if (!Strings.isNullOrEmpty(temp) && !temp.equals("-1")) {
- int tempVal = Integer.parseInt(temp);
- if (tempVal <= 0) {
- throw new DdlException("Dimensions must be a positive
integer");
- }
- }
- }
-
- private static boolean hasAnyProperty(Map<String, String> properties,
List<String> requiredFields,
- String apiKeyField) {
- return properties.containsKey(apiKeyField) ||
requiredFields.stream().anyMatch(properties::containsKey);
- }
-
- private static void validatePropertyGroup(Map<String, String> properties,
List<String> requiredFields,
- String providerTypeField, String apiKeyField) throws DdlException {
- for (String field : requiredFields) {
- if (Strings.isNullOrEmpty(properties.get(field))) {
- throw new DdlException("Missing [" + field + "] in
properties.");
- }
- }
-
- String providerType = properties.get(providerTypeField).toUpperCase();
- properties.put(providerTypeField, providerType);
- if (!PROVIDERS.contains(providerType)) {
- throw new DdlException("Provider must be one of " + PROVIDERS);
- }
-
- if (!"LOCAL".equals(providerType) &&
Strings.isNullOrEmpty(properties.get(apiKeyField))) {
- throw new DdlException("Missing [" + apiKeyField + "] in
properties for provider: " + providerType);
- }
- }
-
- public static void optionalAIProperties(Map<String, String> properties) {
- properties.putIfAbsent(ENABLE_STRUCTURED_OUTPUT,
DEFAULT_ENABLE_STRUCTURED_OUTPUT);
- properties.putIfAbsent(AIProperties.TEMPERATURE,
AIProperties.DEFAULT_TEMPERATURE);
- properties.putIfAbsent(AIProperties.MAX_TOKEN,
AIProperties.DEFAULT_MAX_TOKEN);
- properties.putIfAbsent(AIProperties.MAX_RETRIES,
AIProperties.DEFAULT_MAX_RETRIES);
- properties.putIfAbsent(AIProperties.RETRY_DELAY_SECOND,
AIProperties.DEFAULT_RETRY_DELAY_SECOND);
- properties.putIfAbsent(AIProperties.ANTHROPIC_VERSION,
AIProperties.DEFAULT_ANTHROPIC_VERSION);
- properties.putIfAbsent(AIProperties.DIMENSIONS,
AIProperties.DEFAULT_DIMENSIONS);
- }
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
index 746afaae700..f67ac12251a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
@@ -76,9 +76,11 @@ import
org.apache.doris.nereids.trees.expressions.functions.LambdaBinding;
import org.apache.doris.nereids.trees.expressions.functions.LambdaBindingSpec;
import org.apache.doris.nereids.trees.expressions.functions.RewriteWhenAnalyze;
import org.apache.doris.nereids.trees.expressions.functions.Udf;
+import org.apache.doris.nereids.trees.expressions.functions.agg.AIAgg;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
import
org.apache.doris.nereids.trees.expressions.functions.agg.NullableAggregateFunction;
import
org.apache.doris.nereids.trees.expressions.functions.agg.SupportMultiDistinct;
+import org.apache.doris.nereids.trees.expressions.functions.ai.AIFunction;
import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt;
import org.apache.doris.nereids.trees.expressions.functions.scalar.Lambda;
import
org.apache.doris.nereids.trees.expressions.functions.udf.AliasUdfBuilder;
@@ -638,13 +640,8 @@ public class ExpressionAnalyzer extends
SubExprAnalyzer<ExpressionRewriteContext
if (wantToParseSqlFromSqlCache) {
sqlCacheContext =
context.cascadesContext.getStatementContext().getSqlCacheContext();
- if (builder instanceof AliasUdfBuilder
- || buildResult.second instanceof Udf) {
- if (sqlCacheContext.isPresent()) {
- sqlCacheContext.get().setCannotProcessExpression(true);
- }
- }
}
+ disableSqlCacheForUnsupportedFunction(buildResult.second);
if (unboundFunction.isSkew() && unboundFunction.isDistinct() &&
buildResult.first instanceof AggregateFunction
&& buildResult.first instanceof SupportMultiDistinct) {
Expression bound = ((AggregateFunction)
buildResult.first).withIsSkew(true);
@@ -674,9 +671,18 @@ public class ExpressionAnalyzer extends
SubExprAnalyzer<ExpressionRewriteContext
@Override
public Expression visitBoundFunction(BoundFunction boundFunction,
ExpressionRewriteContext context) {
boundFunction = (BoundFunction)
super.visitBoundFunction(boundFunction, context);
+ disableSqlCacheForUnsupportedFunction(boundFunction);
return TypeCoercionUtils.processBoundFunction(boundFunction);
}
+ private void disableSqlCacheForUnsupportedFunction(BoundFunction function)
{
+ if (wantToParseSqlFromSqlCache
+ && (function instanceof Udf || function instanceof AIFunction
|| function instanceof AIAgg)) {
+ getCascadesContext().getStatementContext().getSqlCacheContext()
+ .ifPresent(cache ->
cache.setCannotProcessExpression(true));
+ }
+ }
+
private ElementAt canonicalizeStructSelector(ElementAt elementAt) {
Expression left = elementAt.left();
Expression right = elementAt.right();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
index 7caedc19869..ecb33286e25 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
@@ -24,6 +24,7 @@ import org.apache.doris.catalog.Resource;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.Expression;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
+import org.apache.doris.nereids.trees.expressions.functions.ai.AIFunction;
import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
import org.apache.doris.nereids.types.StringType;
@@ -73,6 +74,11 @@ public class AIAgg extends NullableAggregateFunction
super(functionParams);
}
+ @Override
+ public boolean isDeterministic() {
+ return false;
+ }
+
@Override
public void checkLegalityAfterRewrite() {
// The rewrite has folded a constant task and resource name, unless
constant folding is skipped
@@ -113,6 +119,7 @@ public class AIAgg extends NullableAggregateFunction
private void checkResource(Expression resourceNameArgument) {
//Check if the resource is valid
String resourceName =
resourceNameArgument.toString().replaceAll("^['\"]|['\"]$", "");
+ AIFunction.checkResourceUsagePrivilege(resourceName);
Resource resource =
Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
if (!(resource instanceof AIResource)) {
throw new AnalysisException("AI resource '" + resourceName + "'
does not exist");
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
index 64180adec7b..ddccffadba7 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
@@ -20,6 +20,8 @@ package
org.apache.doris.nereids.trees.expressions.functions.ai;
import org.apache.doris.catalog.AIResource;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.Resource;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.Expression;
import
org.apache.doris.nereids.trees.expressions.functions.ExplicitlyCastableSignature;
@@ -48,6 +50,11 @@ public abstract class AIFunction extends ScalarFunction
public abstract int getMaxArgsNum();
+ @Override
+ public boolean isDeterministic() {
+ return false;
+ }
+
@Override
public void checkLegalityAfterRewrite() {
// The rewrite has folded a constant resource name, unless constant
folding is skipped
@@ -66,6 +73,7 @@ public abstract class AIFunction extends ScalarFunction
//Check if the resource is valid
String resourceName =
resourceNameArgument.toString().replaceAll("^['\"]|['\"]$", "");
+ checkResourceUsagePrivilege(resourceName);
Resource resource =
Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
if (!(resource instanceof AIResource)) {
throw new AnalysisException("AI resource '" + resourceName + "'
does not exist");
@@ -77,6 +85,14 @@ public abstract class AIFunction extends ScalarFunction
Resource.registerUsedAIResourceName(resourceName);
}
+ public static void checkResourceUsagePrivilege(String resourceName) {
+ if (!Env.getCurrentEnv().getAccessManager()
+ .checkResourcePriv(ConnectContext.get(), resourceName,
PrivPredicate.USAGE)) {
+ throw new
AnalysisException(ErrorCode.ERR_RESOURCE_ACCESS_DENIED_ERROR.formatErrorMsg(
+ PrivPredicate.USAGE.getPrivs().toString(), resourceName));
+ }
+ }
+
/**
* If a user doesn't specify which resource to use when calling a function
* the current session variable will automatically allocate a resource.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
index b561de799f6..e75b80f63a4 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
@@ -132,6 +132,7 @@ public class Embed extends AIFunction {
}
private static void validateAIResource(String resourceName, boolean
isMultimodal) {
+ checkResourceUsagePrivilege(resourceName);
Resource resource =
Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
if (!(resource instanceof AIResource)) {
throw new AnalysisException("AI resource '" + resourceName + "'
does not exist");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceConcurrencyTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceConcurrencyTest.java
new file mode 100644
index 00000000000..3ffad214708
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceConcurrencyTest.java
@@ -0,0 +1,134 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.catalog;
+
+import org.apache.doris.common.proc.BaseProcResult;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TAIResource;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public class AIResourceConcurrencyTest {
+ private ImmutableMap<String, String> properties(String suffix) {
+ return ImmutableMap.<String, String>builder()
+ .put("ai.provider_type", "LOCAL")
+ .put("ai.endpoint", "http://localhost:8000/" + suffix)
+ .put("ai.model_name", "model-" + suffix)
+ .put("ai.api_key", "key-" + suffix)
+ .put("ai.embed.provider_type", "LOCAL")
+ .put("ai.embed.endpoint", "http://localhost:8001/" + suffix)
+ .put("ai.embed.model_name", "embed-" + suffix)
+ .put("ai.embed.api_key", "embed-key-" + suffix)
+ .put("ai.embed.mm.provider_type", "LOCAL")
+ .put("ai.embed.mm.endpoint", "http://localhost:8002/" + suffix)
+ .put("ai.embed.mm.model_name", "multimodal-" + suffix)
+ .put("ai.embed.mm.api_key", "multimodal-key-" + suffix)
+ .build();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testConcurrentAlterKeepsCompleteSnapshots(boolean restored)
throws Exception {
+ AIResource created = new AIResource("concurrent-snapshots");
+ created.setProperties(properties("0"));
+ AIResource resource = restored
+ ? (AIResource)
GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(created), Resource.class) :
created;
+ Map<String, String> original = resource.getCopiedProperties();
+ TAIResource originalThrift = resource.toThrift();
+ AIResource alternate = new AIResource("alternate-snapshot");
+ alternate.setProperties(properties("1"));
+ Map<String, String> changed = alternate.getCopiedProperties();
+ TAIResource changedThrift = alternate.toThrift();
+ ImmutableMap<String, String> zero = properties("0");
+ ImmutableMap<String, String> one = properties("1");
+ CountDownLatch ready = new CountDownLatch(3);
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(3);
+ try {
+ Future<?> writer = executor.submit(() -> {
+ ready.countDown();
+ Assertions.assertTrue(start.await(10, TimeUnit.SECONDS));
+ for (int i = 1; i <= 1000; ++i) {
+ resource.modifyProperties(i % 2 == 0 ? zero : one);
+ }
+ return null;
+ });
+ Future<?> reader = executor.submit(() -> {
+ ready.countDown();
+ Assertions.assertTrue(start.await(10, TimeUnit.SECONDS));
+ for (int i = 0; i < 1000; ++i) {
+ Map<String, String> snapshot =
resource.getCopiedProperties();
+ Assertions.assertTrue(snapshot.equals(original) ||
snapshot.equals(changed));
+ TAIResource thrift = resource.toThrift();
+ Assertions.assertTrue(thrift.equals(originalThrift) ||
thrift.equals(changedThrift));
+
Assertions.assertTrue(resource.hasCompleteGeneralProperties());
+
Assertions.assertTrue(resource.hasCompleteEmbedProperties());
+
Assertions.assertTrue(resource.hasCompleteMultimodalEmbedProperties());
+ }
+ return null;
+ });
+ Future<?> procReader = executor.submit(() -> {
+ ready.countDown();
+ Assertions.assertTrue(start.await(10, TimeUnit.SECONDS));
+ for (int i = 0; i < 1000; ++i) {
+ BaseProcResult result = new BaseProcResult();
+ resource.getProcNodeData(result);
+ long version = Long.parseLong(procValue(result.getRows(),
"version"));
+ String suffix = Long.toString((version + 1) % 2);
+ Assertions.assertEquals("model-" + suffix,
procValue(result.getRows(), "ai.model_name"));
+ Assertions.assertEquals("embed-" + suffix,
procValue(result.getRows(), "ai.embed.model_name"));
+ Assertions.assertEquals("multimodal-" + suffix,
+ procValue(result.getRows(),
"ai.embed.mm.model_name"));
+ Assertions.assertEquals("******",
procValue(result.getRows(), "ai.api_key"));
+ }
+ return null;
+ });
+ Assertions.assertTrue(ready.await(10, TimeUnit.SECONDS));
+ start.countDown();
+ writer.get(30, TimeUnit.SECONDS);
+ reader.get(30, TimeUnit.SECONDS);
+ procReader.get(30, TimeUnit.SECONDS);
+ Assertions.assertEquals(999, resource.getVersion());
+ Assertions.assertEquals(original, resource.getCopiedProperties());
+ Assertions.assertEquals("model-0",
resource.getProperty("ai.model_name"));
+ original.put("ai.model_name", "external-change");
+ Assertions.assertEquals("model-0",
resource.getProperty("ai.model_name"));
+ Assertions.assertEquals("model-0", originalThrift.getModelName());
+ } finally {
+ start.countDown();
+ executor.shutdownNow();
+ Assertions.assertTrue(executor.awaitTermination(10,
TimeUnit.SECONDS));
+ }
+ }
+
+ private String procValue(List<List<String>> rows, String key) {
+ return rows.stream().filter(row -> key.equals(row.get(2)))
+ .map(row ->
row.get(3)).findFirst().orElseThrow(AssertionError::new);
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourcePropertiesTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourcePropertiesTest.java
new file mode 100644
index 00000000000..cec999c0912
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourcePropertiesTest.java
@@ -0,0 +1,516 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.catalog;
+
+import org.apache.doris.common.DdlException;
+import org.apache.doris.datasource.property.AIResourceProperties;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.thrift.TAIResource;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Stream;
+
+public class AIResourcePropertiesTest {
+ private Map<String, String> generalProperties() {
+ return new HashMap<>(ImmutableMap.of(
+ "ai.endpoint", "http://localhost:8000/v1/chat/completions",
+ "ai.provider_type", "local",
+ "ai.model_name", "chat-model"));
+ }
+
+ @Test
+ public void testTypedDefaultsAndPropertyOwnership() {
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.validity_check", "false");
+ raw.put("custom.property", "retained");
+ AIResourceProperties properties = AIResourceProperties.of(raw);
+ raw.put("ai.model_name", "changed-model");
+
+ TAIResource thrift = properties.toThrift();
+ Assertions.assertEquals("LOCAL", thrift.getProviderType());
+ Assertions.assertEquals("chat-model", thrift.getModelName());
+ Assertions.assertFalse(thrift.isSetApiKey());
+ Assertions.assertEquals(-1.0, thrift.getTemperature());
+ Assertions.assertEquals(-1L, thrift.getMaxTokens());
+ Assertions.assertEquals(3, thrift.getMaxRetries());
+ Assertions.assertEquals(1, thrift.getRetryDelaySecond());
+ Assertions.assertEquals(-1, thrift.getDimensions());
+ Assertions.assertEquals("2023-06-01", thrift.getAnthropicVersion());
+ Assertions.assertTrue(properties.hasCompleteGeneralProperties());
+ Assertions.assertFalse(properties.hasCompleteEmbedProperties());
+
Assertions.assertFalse(properties.hasCompleteMultimodalEmbedProperties());
+ Assertions.assertEquals("false",
properties.toMap().get("ai.validity_check"));
+ Assertions.assertEquals("retained",
properties.toMap().get("custom.property"));
+ properties.toMap().put("ai.model_name", "another-model");
+ Assertions.assertEquals("chat-model",
properties.toThrift().getModelName());
+ }
+
+ @Test
+ public void testIndependentEmbedGroups() {
+ AIResourceProperties embed = AIResourceProperties.of(ImmutableMap.of(
+ "ai.embed.endpoint", "http://localhost:8000/v1/embeddings",
+ "ai.embed.provider_type", "local",
+ "ai.embed.model_name", "text-model"));
+ Assertions.assertFalse(embed.hasCompleteGeneralProperties());
+ Assertions.assertTrue(embed.hasCompleteEmbedProperties());
+ Assertions.assertFalse(embed.hasCompleteMultimodalEmbedProperties());
+ Assertions.assertFalse(embed.toThrift().isSetProviderType());
+ Assertions.assertEquals("LOCAL",
embed.toThrift().getEmbedProviderType());
+ Assertions.assertEquals("text-model",
embed.toThrift().getEmbedModelName());
+
+ AIResourceProperties multimodal =
AIResourceProperties.of(ImmutableMap.of(
+ "ai.embed.mm.endpoint", "http://localhost:8000/v1/embeddings",
+ "ai.embed.mm.provider_type", "local",
+ "ai.embed.mm.model_name", "image-model"));
+ Assertions.assertFalse(multimodal.hasCompleteGeneralProperties());
+ Assertions.assertFalse(multimodal.hasCompleteEmbedProperties());
+
Assertions.assertTrue(multimodal.hasCompleteMultimodalEmbedProperties());
+ Assertions.assertEquals("LOCAL",
multimodal.toThrift().getEmbedMmProviderType());
+ Assertions.assertEquals("image-model",
multimodal.toThrift().getEmbedMmModelName());
+ }
+
+ @Test
+ public void testExplicitValuesAndCombinedGroups() {
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.api_key", "general-key");
+ raw.put("ai.embed.endpoint", "http://localhost:8001/embeddings");
+ raw.put("ai.embed.provider_type", "openai");
+ raw.put("ai.embed.model_name", "text-model");
+ raw.put("ai.embed.api_key", "text-key");
+ raw.put("ai.embed.mm.endpoint", "http://localhost:8002/embeddings");
+ raw.put("ai.embed.mm.provider_type", "gemini");
+ raw.put("ai.embed.mm.model_name", "image-model");
+ raw.put("ai.embed.mm.api_key", "image-key");
+ raw.put("ai.temperature", "0.75");
+ raw.put("ai.max_token", "4294967296");
+ raw.put("ai.max_retries", "0");
+ raw.put("ai.retry_delay_second", "0");
+ raw.put("ai.dimensions", "768");
+ raw.put("ai.anthropic_version", "2024-01-01");
+ raw.put("ai.effort", "high");
+ AIResourceProperties properties = AIResourceProperties.of(raw);
+ TAIResource thrift = properties.toThrift();
+ Assertions.assertTrue(properties.hasCompleteGeneralProperties());
+ Assertions.assertTrue(properties.hasCompleteEmbedProperties());
+
Assertions.assertTrue(properties.hasCompleteMultimodalEmbedProperties());
+ Assertions.assertEquals("general-key", thrift.getApiKey());
+ Assertions.assertEquals("text-key", thrift.getEmbedApiKey());
+ Assertions.assertEquals("image-key", thrift.getEmbedMmApiKey());
+ Assertions.assertEquals("http://localhost:8001/embeddings",
thrift.getEmbedEndpoint());
+ Assertions.assertEquals("http://localhost:8002/embeddings",
thrift.getEmbedMmEndpoint());
+ Assertions.assertEquals("OPENAI", thrift.getEmbedProviderType());
+ Assertions.assertEquals("GEMINI", thrift.getEmbedMmProviderType());
+ Assertions.assertEquals(0.75, thrift.getTemperature());
+ Assertions.assertEquals(4294967296L, thrift.getMaxTokens());
+ Assertions.assertEquals(0, thrift.getMaxRetries());
+ Assertions.assertEquals(0, thrift.getRetryDelaySecond());
+ Assertions.assertEquals(768, thrift.getDimensions());
+ Assertions.assertEquals("2024-01-01", thrift.getAnthropicVersion());
+ Assertions.assertEquals("high", thrift.getEffort());
+ Assertions.assertFalse(properties.toString().contains("general-key"));
+ Assertions.assertFalse(properties.toString().contains("text-key"));
+ Assertions.assertFalse(properties.toString().contains("image-key"));
+ }
+
+ @Test
+ public void testExistingGroupAndValueRules() {
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(ImmutableMap.of()));
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.embed.model_name", "partial-model");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(raw));
+ raw.remove("ai.embed.model_name");
+ raw.put("ai.provider_type", "openai");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(raw));
+ raw.put("ai.api_key", "key");
+ raw.put("ai.temperature", "1.5");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(raw));
+ raw.put("ai.temperature", "-1");
+ raw.put("ai.dimensions", "0");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(raw));
+ raw.put("ai.dimensions", "-1");
+ raw.put("ai.provider_type", "anthropic");
+ raw.put("ai.effort", "none");
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
AIResourceProperties.of(raw));
+ raw.put("ai.effort", "high");
+ Assertions.assertEquals("high",
AIResourceProperties.of(raw).toThrift().getEffort());
+ }
+
+ @Test
+ public void testInvalidNumericBinding() {
+ for (String key : new String[] {"ai.temperature", "ai.max_token",
"ai.max_retries",
+ "ai.retry_delay_second", "ai.dimensions"}) {
+ for (String value : new String[] {"", " ", "not-a-number"}) {
+ Map<String, String> raw = generalProperties();
+ raw.put(key, value);
+ IllegalArgumentException failure =
Assertions.assertThrows(IllegalArgumentException.class,
+ () -> AIResourceProperties.of(raw));
+ Assertions.assertTrue(failure.getMessage().contains(key));
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidNumericProperties")
+ public void testInvalidNumericRangesOnCreateAndAlter(String key, String
value) throws Exception {
+ Map<String, String> invalid = generalProperties();
+ invalid.put(key, value);
+ AIResource created = new AIResource("invalid-numeric-resource");
+ DdlException createFailure =
Assertions.assertThrows(DdlException.class,
+ () -> created.setProperties(ImmutableMap.copyOf(invalid)));
+ Assertions.assertTrue(createFailure.getMessage().contains(key));
+
+ AIResource altered = new AIResource("valid-numeric-resource");
+ altered.setProperties(ImmutableMap.copyOf(generalProperties()));
+ Map<String, String> before = altered.getCopiedProperties();
+ long version = altered.version;
+ DdlException alterFailure = Assertions.assertThrows(DdlException.class,
+ () -> altered.modifyProperties(ImmutableMap.of(key, value)));
+ Assertions.assertTrue(alterFailure.getMessage().contains(key));
+ Assertions.assertEquals(before, altered.getCopiedProperties());
+ Assertions.assertEquals(version, altered.version);
+ }
+
+ private static Stream<Arguments> invalidNumericProperties() {
+ return Stream.of(
+ Arguments.of("ai.temperature", "NaN"),
+ Arguments.of("ai.temperature", "Infinity"),
+ Arguments.of("ai.temperature", "-Infinity"),
+ Arguments.of("ai.temperature", "1e309"),
+ Arguments.of("ai.temperature", "-0.1"),
+ Arguments.of("ai.temperature", "1.1"),
+ Arguments.of("ai.temperature", "-2"),
+ Arguments.of("ai.max_token", "0"),
+ Arguments.of("ai.max_token", "-2"),
+ Arguments.of("ai.max_token", "-9223372036854775808"),
+ Arguments.of("ai.max_token", "9223372036854775808"),
+ Arguments.of("ai.max_token", "1.5"),
+ Arguments.of("ai.max_retries", "-1"),
+ Arguments.of("ai.max_retries", "-2147483648"),
+ Arguments.of("ai.max_retries", "2147483648"),
+ Arguments.of("ai.max_retries", "1.5"),
+ Arguments.of("ai.retry_delay_second", "-1"),
+ Arguments.of("ai.retry_delay_second", "-2147483648"),
+ Arguments.of("ai.retry_delay_second", "2147483648"),
+ Arguments.of("ai.retry_delay_second", "1.5"),
+ Arguments.of("ai.dimensions", "0"),
+ Arguments.of("ai.dimensions", "-2"),
+ Arguments.of("ai.dimensions", "-2147483648"),
+ Arguments.of("ai.dimensions", "2147483648"),
+ Arguments.of("ai.dimensions", "1.5"));
+ }
+
+ @Test
+ public void testNumericBoundaryValues() throws Exception {
+ Map<String, String> raw = generalProperties();
+ raw.putAll(ImmutableMap.of("ai.temperature", "1", "ai.max_token",
"9223372036854775807",
+ "ai.max_retries", "2147483647", "ai.retry_delay_second",
"2147483647",
+ "ai.dimensions", "2147483647"));
+ AIResource resource = new AIResource("numeric-boundaries");
+ resource.setProperties(ImmutableMap.copyOf(raw));
+ TAIResource maximum = resource.toThrift();
+ Assertions.assertEquals(1.0, maximum.getTemperature());
+ Assertions.assertEquals(Long.MAX_VALUE, maximum.getMaxTokens());
+ Assertions.assertEquals(Integer.MAX_VALUE, maximum.getMaxRetries());
+ Assertions.assertEquals(Integer.MAX_VALUE,
maximum.getRetryDelaySecond());
+ Assertions.assertEquals(Integer.MAX_VALUE, maximum.getDimensions());
+
+ resource.modifyProperties(ImmutableMap.of("ai.temperature", "0",
"ai.max_token", "1",
+ "ai.max_retries", "0", "ai.retry_delay_second", "0",
"ai.dimensions", "1"));
+ TAIResource minimum = resource.toThrift();
+ Assertions.assertEquals(0.0, minimum.getTemperature());
+ Assertions.assertEquals(1L, minimum.getMaxTokens());
+ Assertions.assertEquals(0, minimum.getMaxRetries());
+ Assertions.assertEquals(0, minimum.getRetryDelaySecond());
+ Assertions.assertEquals(1, minimum.getDimensions());
+
+ resource.modifyProperties(ImmutableMap.of("ai.temperature", "-1",
"ai.max_token", "-1",
+ "ai.dimensions", "-1"));
+ Assertions.assertEquals(-1.0, resource.toThrift().getTemperature());
+ Assertions.assertEquals(-1L, resource.toThrift().getMaxTokens());
+ Assertions.assertEquals(-1, resource.toThrift().getDimensions());
+ }
+
+ @Test
+ public void testProviderNormalizationIsLocaleIndependent() throws
Exception {
+ Locale original = Locale.getDefault();
+ try {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"));
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.provider_type", "minimax");
+ raw.put("ai.api_key", "general-key");
+ raw.put("ai.embed.endpoint", "http://localhost:8001/embeddings");
+ raw.put("ai.embed.provider_type", "gemini");
+ raw.put("ai.embed.model_name", "text-model");
+ raw.put("ai.embed.api_key", "text-key");
+ raw.put("ai.embed.mm.endpoint",
"http://localhost:8002/embeddings");
+ raw.put("ai.embed.mm.provider_type", "jina");
+ raw.put("ai.embed.mm.model_name", "image-model");
+ raw.put("ai.embed.mm.api_key", "image-key");
+ AIResource resource = new
AIResource("locale-independent-resource");
+ resource.setProperties(ImmutableMap.copyOf(raw));
+ Assertions.assertEquals("MINIMAX",
resource.toThrift().getProviderType());
+ Assertions.assertEquals("GEMINI",
resource.toThrift().getEmbedProviderType());
+ Assertions.assertEquals("JINA",
resource.toThrift().getEmbedMmProviderType());
+ resource.modifyProperties(ImmutableMap.of("ai.provider_type",
"jina",
+ "ai.embed.provider_type", "minimax",
"ai.embed.mm.provider_type", "gemini"));
+ Assertions.assertEquals("JINA",
resource.getProperty("ai.provider_type"));
+ Assertions.assertEquals("MINIMAX",
resource.getProperty("ai.embed.provider_type"));
+ Assertions.assertEquals("GEMINI",
resource.getProperty("ai.embed.mm.provider_type"));
+ } finally {
+ Locale.setDefault(original);
+ }
+ }
+
+ @Test
+ public void testLegacyOutOfRangeMetadataLoadsAndCanBeRepaired() throws
Exception {
+ Map<String, String> invalid = ImmutableMap.of(
+ "ai.max_token", "0", "ai.max_retries", "-1",
"ai.retry_delay_second", "-1");
+ for (Map.Entry<String, String> entry : invalid.entrySet()) {
+ Map<String, String> raw = generalProperties();
+ raw.put(entry.getKey(), entry.getValue());
+ AIResource loaded = readMetadata(raw);
+ Assertions.assertEquals(entry.getValue(),
loaded.getProperty(entry.getKey()));
+ Assertions.assertTrue(loaded.hasCompleteGeneralProperties());
+ IllegalArgumentException failure =
Assertions.assertThrows(IllegalArgumentException.class,
+ loaded::toThrift);
+
Assertions.assertTrue(failure.getMessage().contains(entry.getKey()));
+ loaded.modifyProperties(ImmutableMap.of(entry.getKey(), "1"));
+ Assertions.assertNotNull(loaded.toThrift());
+ }
+ }
+
+ @Test
+ public void testResourceAlterAndPersistence() throws Exception {
+ AIResource resource = new AIResource("typed-resource");
+ resource.setProperties(ImmutableMap.copyOf(generalProperties()));
+ resource.modifyProperties(ImmutableMap.of("ai.provider_type",
"openai", "ai.api_key", "key",
+ "ai.effort", "high", "ai.max_token", "2048"));
+ Assertions.assertEquals("OPENAI",
resource.getProperty("ai.provider_type"));
+ Assertions.assertEquals(2048L, resource.toThrift().getMaxTokens());
+ Map<String, String> before = resource.getCopiedProperties();
+ long version = resource.version;
+ Assertions.assertThrows(DdlException.class,
+ () -> resource.modifyProperties(ImmutableMap.of("ai.effort",
"invalid")));
+ Assertions.assertEquals(before, resource.getCopiedProperties());
+ Assertions.assertEquals(version, resource.version);
+ Assertions.assertThrows(DdlException.class,
+ () -> resource.modifyProperties(ImmutableMap.of("ai.effort",
"")));
+ Assertions.assertEquals("high", resource.toThrift().getEffort());
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream output = new DataOutputStream(bytes)) {
+ resource.write(output);
+ }
+ try (DataInputStream input = new DataInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ AIResource loaded = (AIResource) Resource.read(input);
+ Assertions.assertEquals(resource.getCopiedProperties(),
loaded.getCopiedProperties());
+ Assertions.assertEquals(resource.toThrift(), loaded.toThrift());
+ }
+ }
+
+ @Test
+ public void testMetadataLoadingDoesNotValidateProperties() {
+ String json =
"{\"clazz\":\"AIResource\",\"name\":\"legacy-ai\",\"type\":\"AI\","
+ +
"\"properties\":{\"ai.provider_type\":\"LOCAL\",\"ai.endpoint\":\"http://localhost:8000\","
+ +
"\"ai.model_name\":\"legacy-model\",\"ai.max_retries\":\"not-an-integer\","
+ + "\"ai.validity_check\":\"false\"}}";
+ AIResource loaded = (AIResource) GsonUtils.GSON.fromJson(json,
Resource.class);
+ Assertions.assertEquals("not-an-integer",
loaded.getProperty("ai.max_retries"));
+ Assertions.assertEquals("false",
loaded.getProperty("ai.validity_check"));
+ Assertions.assertTrue(loaded.hasCompleteGeneralProperties());
+ Assertions.assertFalse(loaded.hasCompleteEmbedProperties());
+ Assertions.assertFalse(loaded.hasCompleteMultimodalEmbedProperties());
+ Assertions.assertThrows(IllegalArgumentException.class,
loaded::toThrift);
+ }
+
+ @Test
+ public void testStringValuesAreTrimmed() {
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.model_name", " model ");
+ raw.put("ai.endpoint", " http://localhost:8000 ");
+ raw.put("ai.api_key", " key ");
+ raw.put("ai.anthropic_version", " 2023-06-01 ");
+ AIResourceProperties properties = AIResourceProperties.of(raw);
+ TAIResource thrift = properties.toThrift();
+ Assertions.assertEquals("model", thrift.getModelName());
+ Assertions.assertEquals("http://localhost:8000", thrift.getEndpoint());
+ Assertions.assertEquals("key", thrift.getApiKey());
+ Assertions.assertEquals("2023-06-01", thrift.getAnthropicVersion());
+ Assertions.assertEquals("model",
properties.toMap().get("ai.model_name"));
+ Assertions.assertEquals("http://localhost:8000",
properties.toMap().get("ai.endpoint"));
+ Assertions.assertEquals("key", properties.toMap().get("ai.api_key"));
+ Assertions.assertEquals("2023-06-01",
properties.toMap().get("ai.anthropic_version"));
+ }
+
+ @Test
+ public void testCreateAndAlterPersistNormalizedProperties() throws
Exception {
+ Map<String, String> expected = new HashMap<>(ImmutableMap.<String,
String>builder()
+ .put("ai.provider_type", "LOCAL")
+ .put("ai.endpoint", "http://localhost:8000/chat")
+ .put("ai.model_name", "chat-model")
+ .put("ai.api_key", "chat-key")
+ .put("ai.embed.provider_type", "OPENAI")
+ .put("ai.embed.endpoint", "http://localhost:8001/embeddings")
+ .put("ai.embed.model_name", "text-model")
+ .put("ai.embed.api_key", "text-key")
+ .put("ai.embed.mm.provider_type", "GEMINI")
+ .put("ai.embed.mm.endpoint",
"http://localhost:8002/embeddings")
+ .put("ai.embed.mm.model_name", "image-model")
+ .put("ai.embed.mm.api_key", "image-key")
+ .put("ai.temperature", "0.75")
+ .put("ai.max_token", "4294967296")
+ .put("ai.max_retries", "3")
+ .put("ai.retry_delay_second", "2")
+ .put("ai.dimensions", "768")
+ .put("ai.anthropic_version", "2023-06-01")
+ .put("ai.effort", "high")
+ .put("ai.enable_structured_output", "true")
+ .build());
+ Map<String, String> raw = new HashMap<>();
+ expected.forEach((key, value) -> raw.put(key, " \t" + value + "\t "));
+ raw.put("custom.property", " retained ");
+ raw.put("ai.validity_check", " false ");
+ expected.put("custom.property", " retained ");
+ expected.put("ai.validity_check", " false ");
+ Assertions.assertEquals(expected,
AIResourceProperties.of(raw).toMap());
+ Assertions.assertEquals(" \t768\t ", raw.get("ai.dimensions"));
+
+ AIResource resource = new AIResource("normalized-resource");
+ resource.setProperties(ImmutableMap.copyOf(raw));
+ assertNormalizedMetadata(resource, expected);
+
+ resource.modifyProperties(ImmutableMap.of("ai.dimensions", " 1024 ",
+ "ai.max_retries", " 4 ", "ai.model_name", " updated-model ",
+ "ai.embed.api_key", " updated-key ",
"ai.enable_structured_output", " false "));
+ expected.putAll(ImmutableMap.of("ai.dimensions", "1024",
"ai.max_retries", "4",
+ "ai.model_name", "updated-model", "ai.embed.api_key",
"updated-key",
+ "ai.enable_structured_output", "false"));
+ assertNormalizedMetadata(resource, expected);
+ }
+
+ private void assertNormalizedMetadata(AIResource resource, Map<String,
String> expected) throws Exception {
+ Assertions.assertEquals(expected, resource.getCopiedProperties());
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (DataOutputStream output = new DataOutputStream(bytes)) {
+ resource.write(output);
+ }
+ try (DataInputStream input = new DataInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ AIResource loaded = (AIResource) Resource.read(input);
+ Assertions.assertEquals(expected, loaded.getCopiedProperties());
+ Assertions.assertEquals(resource.toThrift(), loaded.toThrift());
+ Assertions.assertDoesNotThrow(() ->
Double.parseDouble(loaded.getProperty("ai.temperature")));
+ Assertions.assertDoesNotThrow(() ->
Long.parseLong(loaded.getProperty("ai.max_token")));
+ Assertions.assertDoesNotThrow(() ->
Integer.parseInt(loaded.getProperty("ai.max_retries")));
+ Assertions.assertDoesNotThrow(() ->
Integer.parseInt(loaded.getProperty("ai.retry_delay_second")));
+ Assertions.assertDoesNotThrow(() ->
Integer.parseInt(loaded.getProperty("ai.dimensions")));
+
Assertions.assertEquals(resource.toThrift().isEnableStructuredOutput(),
+
Boolean.parseBoolean(loaded.getProperty("ai.enable_structured_output")));
+ }
+ }
+
+ @Test
+ public void testRejectBlankCreateAndAlterProperties() throws Exception {
+ AIResource resource = new AIResource("non-empty-properties");
+ resource.setProperties(ImmutableMap.copyOf(generalProperties()));
+ Map<String, String> before = resource.getCopiedProperties();
+ long version = resource.version;
+ for (String key : new String[] {"ai.endpoint", "ai.provider_type",
"ai.model_name", "ai.api_key",
+ "ai.embed.endpoint", "ai.embed.provider_type",
"ai.embed.model_name", "ai.embed.api_key",
+ "ai.embed.mm.endpoint", "ai.embed.mm.provider_type",
"ai.embed.mm.model_name", "ai.embed.mm.api_key",
+ "ai.temperature", "ai.max_token", "ai.max_retries",
"ai.retry_delay_second", "ai.dimensions",
+ "ai.anthropic_version", "ai.effort"}) {
+ for (String value : new String[] {"", " \t ", null}) {
+ Map<String, String> raw = generalProperties();
+ raw.put(key, value);
+ IllegalArgumentException failure =
Assertions.assertThrows(IllegalArgumentException.class,
+ () -> AIResourceProperties.of(raw));
+ Assertions.assertTrue(failure.getMessage().contains(key));
+ Map<String, String> updates = new HashMap<>();
+ updates.put(key, value);
+ Assertions.assertThrows(DdlException.class, () ->
resource.modifyProperties(updates));
+ Assertions.assertEquals(before,
resource.getCopiedProperties());
+ Assertions.assertEquals(version, resource.version);
+ }
+ }
+ }
+
+ @Test
+ public void testLegacyPaddedMetadata() {
+ Map<String, String> raw = generalProperties();
+ raw.put("ai.provider_type", " local ");
+ raw.put("ai.model_name", " model ");
+ raw.put("ai.endpoint", " http://localhost:8000 ");
+ raw.put("ai.max_retries", " 3 ");
+ AIResource loaded = readMetadata(raw);
+ Assertions.assertTrue(loaded.hasCompleteGeneralProperties());
+ Assertions.assertEquals("LOCAL", loaded.toThrift().getProviderType());
+ Assertions.assertEquals("model", loaded.toThrift().getModelName());
+ Assertions.assertEquals("http://localhost:8000",
loaded.toThrift().getEndpoint());
+ Assertions.assertEquals(3, loaded.toThrift().getMaxRetries());
+ Assertions.assertEquals(raw, loaded.getCopiedProperties());
+ }
+
+ @Test
+ public void testLegacyBlankMetadataLoadsAndCanBeRepaired() throws
Exception {
+ for (String key : new String[] {"ai.model_name",
"ai.anthropic_version", "ai.effort", "ai.max_retries"}) {
+ for (String value : new String[] {"", " \t "}) {
+ Map<String, String> raw = generalProperties();
+ raw.put(key, value);
+ AIResource loaded = readMetadata(raw);
+ Assertions.assertEquals(value, loaded.getProperty(key));
+ Assertions.assertEquals(!key.equals("ai.model_name"),
loaded.hasCompleteGeneralProperties());
+ IllegalArgumentException failure =
Assertions.assertThrows(IllegalArgumentException.class,
+ loaded::toThrift);
+ Assertions.assertTrue(failure.getMessage().contains(key));
+ loaded.modifyProperties(ImmutableMap.of(key,
key.equals("ai.max_retries") ? "3" : "high"));
+ Assertions.assertNotNull(loaded.toThrift());
+ }
+ }
+ }
+
+ private AIResource readMetadata(Map<String, String> properties) {
+ String json = GsonUtils.GSON.toJson(ImmutableMap.of(
+ "clazz", "AIResource", "name", "legacy", "type", "AI",
"properties", properties));
+ return (AIResource) GsonUtils.GSON.fromJson(json, Resource.class);
+ }
+
+ @Test
+ public void testIncompleteMetadataReturnsGroupCompleteness() {
+ AIResource loaded = (AIResource) GsonUtils.GSON.fromJson(
+
"{\"clazz\":\"AIResource\",\"name\":\"partial\",\"type\":\"AI\","
+ +
"\"properties\":{\"ai.endpoint\":\"http://localhost:8000\","
+ +
"\"ai.embed.provider_type\":\"local\",\"ai.embed.model_name\":\"text-model\","
+ + "\"ai.embed.endpoint\":\"http://localhost:8001\"}}",
Resource.class);
+ Assertions.assertFalse(loaded.hasCompleteGeneralProperties());
+ Assertions.assertTrue(loaded.hasCompleteEmbedProperties());
+ Assertions.assertFalse(loaded.hasCompleteMultimodalEmbedProperties());
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceTest.java
index 7ac4c4cd7cf..4aeca1988f2 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/AIResourceTest.java
@@ -17,6 +17,9 @@
package org.apache.doris.catalog;
+import org.apache.doris.analysis.PasswordOptions;
+import org.apache.doris.analysis.ResourcePattern;
+import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.FeConstants;
@@ -27,19 +30,38 @@ import org.apache.doris.common.proc.BaseProcResult;
import org.apache.doris.datasource.property.constants.AIProperties;
import org.apache.doris.meta.MetaContext;
import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.Auth;
+import org.apache.doris.mysql.privilege.PrivBitSet;
import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.mysql.privilege.Privilege;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.SqlCacheContext;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.analyzer.UnboundFunction;
import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.properties.PhysicalProperties;
+import org.apache.doris.nereids.rules.analysis.ExpressionAnalyzer;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.functions.BoundFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.AIAgg;
+import org.apache.doris.nereids.trees.expressions.functions.ai.AIGenerate;
import org.apache.doris.nereids.trees.expressions.functions.ai.AISentiment;
import org.apache.doris.nereids.trees.expressions.functions.ai.Embed;
import org.apache.doris.nereids.trees.expressions.literal.JsonLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
+import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.commands.CreateResourceCommand;
import org.apache.doris.nereids.trees.plans.commands.info.CreateResourceInfo;
+import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation;
+import org.apache.doris.persist.DropResourceOperationLog;
import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.PrivInfo;
import org.apache.doris.persist.gson.GsonUtils;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.OriginStatement;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -48,6 +70,8 @@ import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -359,82 +383,204 @@ public class AIResourceTest {
@Test
public void testRejectEmbedOnlyResourceForNonEmbedScalarFunction() throws
DdlException {
AIResource aiResource = createEmbedOnlyResource();
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- ResourceMgr resourceMgr = Mockito.mock(ResourceMgr.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getResourceMgr()).thenReturn(resourceMgr);
-
Mockito.when(resourceMgr.getResource("embed-only-resource")).thenReturn(aiResource);
-
+ withAIResource(aiResource, () -> {
AISentiment function = new AISentiment(new
StringLiteral("embed-only-resource"),
new StringLiteral("text"));
- Assertions.assertThrows(AnalysisException.class,
function::checkLegalityAfterRewrite);
- }
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ function::checkLegalityAfterRewrite);
+ Assertions.assertTrue(exception.getMessage().contains("does not
contain complete general AI properties"));
+ });
}
@Test
public void testRejectEmbedOnlyResourceForAiAgg() throws DdlException {
AIResource aiResource = createEmbedOnlyResource();
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- ResourceMgr resourceMgr = Mockito.mock(ResourceMgr.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getResourceMgr()).thenReturn(resourceMgr);
-
Mockito.when(resourceMgr.getResource("embed-only-resource")).thenReturn(aiResource);
-
+ withAIResource(aiResource, () -> {
AIAgg function = new AIAgg(new
StringLiteral("embed-only-resource"),
new StringLiteral("text"), new StringLiteral("task"));
- Assertions.assertThrows(AnalysisException.class,
function::checkLegalityAfterRewrite);
- }
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ function::checkLegalityAfterRewrite);
+ Assertions.assertTrue(exception.getMessage().contains("does not
contain complete general AI properties"));
+ });
}
@Test
public void testAcceptEmbedOnlyResourceForEmbedFunction() throws
DdlException {
AIResource aiResource = createEmbedOnlyResource();
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- ResourceMgr resourceMgr = Mockito.mock(ResourceMgr.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getResourceMgr()).thenReturn(resourceMgr);
-
Mockito.when(resourceMgr.getResource("embed-only-resource")).thenReturn(aiResource);
-
+ withAIResource(aiResource, () -> {
Embed function = new Embed(new
StringLiteral("embed-only-resource"),
new StringLiteral("text"));
Assertions.assertDoesNotThrow(function::checkLegalityBeforeTypeCoercion);
Assertions.assertDoesNotThrow(function::checkLegalityAfterRewrite);
- }
+ });
}
@Test
public void testAcceptMultimodalEmbedOnlyResourceForJsonEmbed() throws
DdlException {
AIResource aiResource = createMultimodalEmbedOnlyResource();
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- ResourceMgr resourceMgr = Mockito.mock(ResourceMgr.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getResourceMgr()).thenReturn(resourceMgr);
-
Mockito.when(resourceMgr.getResource("multimodal-embed-only-resource")).thenReturn(aiResource);
-
+ withAIResource(aiResource, () -> {
Embed function = new Embed(new
StringLiteral("multimodal-embed-only-resource"),
new JsonLiteral("{\"text\":\"hello\"}"));
Assertions.assertDoesNotThrow(function::checkLegalityBeforeTypeCoercion);
Assertions.assertDoesNotThrow(function::checkLegalityAfterRewrite);
- }
+ });
}
@Test
public void testRejectMultimodalEmbedOnlyResourceForTextEmbed() throws
DdlException {
AIResource aiResource = createMultimodalEmbedOnlyResource();
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- ResourceMgr resourceMgr = Mockito.mock(ResourceMgr.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getResourceMgr()).thenReturn(resourceMgr);
-
Mockito.when(resourceMgr.getResource("multimodal-embed-only-resource")).thenReturn(aiResource);
-
+ withAIResource(aiResource, () -> {
Embed function = new Embed(new
StringLiteral("multimodal-embed-only-resource"),
new StringLiteral("hello"));
- Assertions.assertThrows(AnalysisException.class,
function::checkLegalityBeforeTypeCoercion);
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ function::checkLegalityBeforeTypeCoercion);
+ Assertions.assertTrue(exception.getMessage().contains("does not
have properties required by EMBED"));
+ });
+ }
+
+ private void withAIResource(AIResource resource, Runnable check) {
+ ConnectContext previousContext = ConnectContext.get();
+ ConnectContext context = new ConnectContext();
+ context.setCurrentUserIdentity(UserIdentity.ROOT);
+ context.setRemoteIP("127.0.0.1");
+ context.setThreadLocalInfo();
+ ResourceMgr resourceMgr = Env.getCurrentEnv().getResourceMgr();
+ resourceMgr.replayCreateResource(resource);
+ try {
+ check.run();
+ } finally {
+ resourceMgr.replayDropResource(new
DropResourceOperationLog(resource.getName()));
+ if (previousContext == null) {
+ ConnectContext.remove();
+ } else {
+ previousContext.setThreadLocalInfo();
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"generate", "aggregate", "embed", "multimodal",
"default_generate",
+ "default_aggregate", "default_embed", "default_multimodal",
"null_input"})
+ public void testAIResourceUsagePrivilege(String kind) throws Exception {
+ String resourceName = "ai_resource_privilege_test";
+ UserIdentity user =
UserIdentity.createAnalyzedUserIdentWithIp("ai_resource_privilege_user", "%");
+ AIResource resource = new AIResource(resourceName);
+ resource.setProperties(ImmutableMap.<String, String>builder()
+ .put("ai.provider_type", "LOCAL")
+ .put("ai.endpoint", "http://localhost:8000")
+ .put("ai.model_name", "chat")
+ .put("ai.embed.provider_type", "LOCAL")
+ .put("ai.embed.endpoint", "http://localhost:8001")
+ .put("ai.embed.model_name", "embed")
+ .put("ai.embed.mm.provider_type", "LOCAL")
+ .put("ai.embed.mm.endpoint", "http://localhost:8002")
+ .put("ai.embed.mm.model_name", "multimodal")
+ .build());
+ Auth auth = Env.getCurrentEnv().getAuth();
+ ResourceMgr resourceMgr = Env.getCurrentEnv().getResourceMgr();
+ ConnectContext previousContext = ConnectContext.get();
+ ConnectContext context = new ConnectContext();
+ context.setCurrentUserIdentity(user);
+ context.setRemoteIP("127.0.0.1");
+ context.getSessionVariable().defaultAIResource = resourceName;
+ context.getSessionVariable().setEnableSqlCache(true);
+ context.setThreadLocalInfo();
+ try {
+ auth.replayCreateUser(new PrivInfo(user, null, new byte[0], null,
PasswordOptions.UNSET_OPTION));
+ Assertions.assertTrue(auth.doesUserExist(user));
+ resourceMgr.replayCreateResource(resource);
+ BoundFunction function = aiFunctionForPrivilegeCheck(kind,
resourceName);
+ Assertions.assertFalse(function.isDeterministic());
+ assertResourceAccessDenied(function, resourceName);
+
+ auth.replayGrant(usagePrivilege(user, "another_ai_resource"));
+ assertResourceAccessDenied(function, resourceName);
+
+ auth.replayGrant(usagePrivilege(user, resourceName));
+ Assertions.assertTrue(Env.getCurrentEnv().getAccessManager()
+ .checkResourcePriv(context, resourceName,
PrivPredicate.USAGE));
+ for (Expression expression : ImmutableList.of(function,
+ new UnboundFunction(function.getName(),
function.getArguments()))) {
+ StatementContext statementContext = new
StatementContext(context, new OriginStatement("SELECT 1", 0));
+ context.setStatementContext(statementContext);
+ SqlCacheContext sqlCacheContext =
statementContext.getSqlCacheContext().orElseThrow();
+ function.checkLegalityBeforeTypeCoercion();
+ function.checkLegalityAfterRewrite();
+ Assertions.assertTrue(sqlCacheContext.supportSqlCache());
+ LogicalEmptyRelation plan = new LogicalEmptyRelation(new
RelationId(0), ImmutableList.of());
+ CascadesContext cascadesContext =
CascadesContext.initContext(statementContext,
+ plan, PhysicalProperties.ANY);
+ Expression analyzed = ExpressionAnalyzer.analyzeFunction(plan,
cascadesContext, expression);
+ Assertions.assertTrue(analyzed.containsNondeterministic());
+ Assertions.assertTrue(statementContext.hasNondeterministic());
+ Assertions.assertFalse(sqlCacheContext.supportSqlCache());
+ }
+
+ auth.replayRevoke(usagePrivilege(user, resourceName));
+ Assertions.assertFalse(Env.getCurrentEnv().getAccessManager()
+ .checkResourcePriv(context, resourceName,
PrivPredicate.USAGE));
+ assertResourceAccessDenied(function, resourceName);
+
+ auth.replayGrant(usagePrivilege(user, "*"));
+ function.checkLegalityBeforeTypeCoercion();
+ function.checkLegalityAfterRewrite();
+ auth.replayRevoke(usagePrivilege(user, "*"));
+ assertResourceAccessDenied(function, resourceName);
+ } finally {
+ auth.replayDropUser(user);
+ resourceMgr.replayDropResource(new
DropResourceOperationLog(resourceName));
+ if (previousContext == null) {
+ ConnectContext.remove();
+ } else {
+ previousContext.setThreadLocalInfo();
+ }
+ }
+ }
+
+ private void assertResourceAccessDenied(BoundFunction function, String
resourceName) {
+ AnalysisException before =
Assertions.assertThrows(AnalysisException.class,
+ function::checkLegalityBeforeTypeCoercion);
+ AnalysisException after =
Assertions.assertThrows(AnalysisException.class,
+ function::checkLegalityAfterRewrite);
+ for (AnalysisException exception : new AnalysisException[] {before,
after}) {
+ Assertions.assertTrue(exception.getMessage().contains("Access
denied"), exception.getMessage());
+
Assertions.assertTrue(exception.getMessage().contains("Usage_priv"),
exception.getMessage());
+
Assertions.assertTrue(exception.getMessage().contains(resourceName),
exception.getMessage());
+ }
+ }
+
+ private PrivInfo usagePrivilege(UserIdentity user, String resourceName)
throws Exception {
+ ResourcePattern pattern = new ResourcePattern(resourceName,
ResourceTypeEnum.GENERAL);
+ pattern.analyze();
+ return new PrivInfo(user, pattern,
PrivBitSet.of(Privilege.USAGE_PRIV), null, null);
+ }
+
+ private BoundFunction aiFunctionForPrivilegeCheck(String kind, String
resourceName) {
+ StringLiteral resource = new StringLiteral(resourceName);
+ StringLiteral input = new StringLiteral("input");
+ StringLiteral task = new StringLiteral("summarize");
+ JsonLiteral multimodal = new JsonLiteral("{\"text\":\"input\"}");
+ switch (kind) {
+ case "generate":
+ return new AIGenerate(resource, input);
+ case "aggregate":
+ return new AIAgg(resource, input, task);
+ case "embed":
+ return new Embed(resource, input);
+ case "multimodal":
+ return new Embed(resource, multimodal);
+ case "default_generate":
+ return new AIGenerate(input);
+ case "default_aggregate":
+ return new AIAgg(input, task);
+ case "default_embed":
+ return new Embed(input);
+ case "default_multimodal":
+ return new Embed(multimodal);
+ case "null_input":
+ return new AIGenerate(resource, NullLiteral.INSTANCE);
+ default:
+ throw new IllegalArgumentException(kind);
}
}
@@ -624,35 +770,34 @@ public class AIResourceTest {
}
@Test
- public void testEmptyEffortIsNotForwarded() throws DdlException {
+ public void testEmptyEffortIsRejected() throws DdlException {
Map<String, String> properties = new HashMap<>(aiProperties);
properties.put(AIProperties.EFFORT, "");
AIResource aiResource = new AIResource("empty-effort-resource");
- aiResource.setProperties(ImmutableMap.copyOf(properties));
-
- Assertions.assertFalse(aiResource.toThrift().isSetEffort());
+ Assertions.assertThrows(DdlException.class, () ->
aiResource.setProperties(ImmutableMap.copyOf(properties)));
}
@Test
- public void testClearEffortOnModifyAndPersistence() throws Exception {
+ public void testRejectEmptyEffortOnModifyAndPersistence() throws Exception
{
Map<String, String> properties = new HashMap<>(aiProperties);
properties.put(AIProperties.EFFORT, "high");
- AIResource aiResource = new AIResource("clear-effort-resource");
+ AIResource aiResource = new AIResource("non-empty-effort-resource");
aiResource.setProperties(ImmutableMap.copyOf(properties));
- aiResource.modifyProperties(ImmutableMap.of(AIProperties.EFFORT, ""));
+ Assertions.assertThrows(DdlException.class,
+ () ->
aiResource.modifyProperties(ImmutableMap.of(AIProperties.EFFORT, "")));
- Assertions.assertNull(aiResource.getProperty(AIProperties.EFFORT));
- Assertions.assertFalse(aiResource.toThrift().isSetEffort());
+ Assertions.assertEquals("high",
aiResource.getProperty(AIProperties.EFFORT));
+ Assertions.assertEquals("high", aiResource.toThrift().getEffort());
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
aiResource.write(new DataOutputStream(bytes));
AIResource restoredResource = (AIResource) Resource.read(
new DataInputStream(new
ByteArrayInputStream(bytes.toByteArray())));
-
Assertions.assertNull(restoredResource.getProperty(AIProperties.EFFORT));
- Assertions.assertFalse(restoredResource.toThrift().isSetEffort());
+ Assertions.assertEquals("high",
restoredResource.getProperty(AIProperties.EFFORT));
+ Assertions.assertEquals("high",
restoredResource.toThrift().getEffort());
}
@Test
diff --git a/regression-test/data/ai_p0/test_create_ai_resource.out
b/regression-test/data/ai_p0/test_create_ai_resource.out
new file mode 100644
index 00000000000..fb0d7675a2b
--- /dev/null
+++ b/regression-test/data/ai_p0/test_create_ai_resource.out
@@ -0,0 +1,37 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !normalized_create --
+ai.anthropic_version 2023-06-01
+ai.api_key ******
+ai.dimensions 768
+ai.effort high
+ai.enable_structured_output true
+ai.endpoint https://api.deepseek.com/chat/completions
+ai.max_retries 3
+ai.max_token 1024
+ai.model_name deepseek-chat
+ai.provider_type DEEPSEEK
+ai.retry_delay_second 1
+ai.temperature 0.7
+ai.validity_check false
+id <generated>
+type ai
+version <generated>
+
+-- !normalized_alter --
+ai.anthropic_version 2023-06-01
+ai.api_key ******
+ai.dimensions 1024
+ai.effort high
+ai.enable_structured_output false
+ai.endpoint https://api.deepseek.com/chat/completions
+ai.max_retries 4
+ai.max_token 2048
+ai.model_name updated-model
+ai.provider_type DEEPSEEK
+ai.retry_delay_second 2
+ai.temperature 0.5
+ai.validity_check false
+id <generated>
+type ai
+version <generated>
+
diff --git a/regression-test/suites/ai_p0/test_ai_resource_privilege.groovy
b/regression-test/suites/ai_p0/test_ai_resource_privilege.groovy
new file mode 100644
index 00000000000..707b30218ce
--- /dev/null
+++ b/regression-test/suites/ai_p0/test_ai_resource_privilege.groovy
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_ai_resource_privilege", "p0,auth") {
+ String resourceName = "test_ai_resource_privilege"
+ String userName = "test_ai_resource_privilege_user"
+ String password = "AiResource_12345"
+ def adminConnection = context.getConnection()
+ def queries = [
+ "SELECT AI_GENERATE('${resourceName}', 'input')",
+ "SELECT AI_AGG('${resourceName}', 'input', 'summarize')",
+ "SELECT EMBED('${resourceName}', 'input')",
+ "SELECT EMBED('${resourceName}', CAST('{\"text\":\"input\"}' AS
JSON))",
+ "SELECT AI_GENERATE('input')",
+ "SELECT AI_AGG('input', 'summarize')",
+ "SELECT EMBED('input')",
+ "SELECT EMBED(CAST('{\"text\":\"input\"}' AS JSON))",
+ "SELECT AI_GENERATE('${resourceName}', NULL)",
+ "SELECT AI_GENERATE(CONCAT('${resourceName}', ''), 'input')",
+ "SELECT AI_AGG(CONCAT('${resourceName}', ''), 'input', 'summarize')",
+ "SELECT EMBED(CONCAT('${resourceName}', ''), 'input')"
+ ]
+ sql "DROP USER IF EXISTS '${userName}'"
+ sql "DROP RESOURCE IF EXISTS '${resourceName}'"
+ try {
+ sql """CREATE RESOURCE '${resourceName}' PROPERTIES (
+ 'type' = 'ai',
+ 'ai.provider_type' = 'local',
+ 'ai.endpoint' = 'http://localhost:8000',
+ 'ai.model_name' = 'chat',
+ 'ai.embed.provider_type' = 'local',
+ 'ai.embed.endpoint' = 'http://localhost:8001',
+ 'ai.embed.model_name' = 'embed',
+ 'ai.embed.mm.provider_type' = 'local',
+ 'ai.embed.mm.endpoint' = 'http://localhost:8002',
+ 'ai.embed.mm.model_name' = 'multimodal'
+ )"""
+ sql "CREATE USER '${userName}' IDENTIFIED BY '${password}'"
+ sql "GRANT SELECT_PRIV ON ${context.dbName}.* TO '${userName}'"
+ if (isCloudMode()) {
+ def clusters = sql "SHOW CLUSTERS"
+ assertTrue(!clusters.isEmpty())
+ sql "GRANT USAGE_PRIV ON CLUSTER `${clusters[0][0]}` TO
'${userName}'"
+ }
+ connect(userName, password, adminConnection.getMetaData().getURL()) {
+ sql "SET default_ai_resource = '${resourceName}'"
+ def assertDenied = {
+ queries.each { query ->
+ test {
+ sql "EXPLAIN ${query}"
+ exception "privilege(s) on resource ${resourceName}"
+ }
+ }
+ }
+ assertDenied()
+ sql_impl(adminConnection, "GRANT USAGE_PRIV ON RESOURCE
'${resourceName}' TO '${userName}'")
+ queries.each { query -> sql "EXPLAIN ${query}" }
+ sql_impl(adminConnection, "REVOKE USAGE_PRIV ON RESOURCE
'${resourceName}' FROM '${userName}'")
+ assertDenied()
+
+ sql "SET enable_sql_cache = true"
+ sql "SELECT 20261104"
+ explain {
+ sql "physical plan SELECT 20261104"
+ contains "PhysicalSqlCache"
+ }
+ def nullQueries = [
+ "SELECT AI_GENERATE('${resourceName}', NULL)",
+ "SELECT AI_GENERATE(NULL)",
+ "SELECT AI_GENERATE(CONCAT('${resourceName}', ''), NULL)",
+ "SELECT IF(TRUE, 'ok', AI_GENERATE('${resourceName}', NULL))",
+ "SELECT EMBED('${resourceName}', CAST(NULL AS STRING))",
+ "SELECT EMBED(CAST(NULL AS STRING))",
+ "SELECT EMBED('${resourceName}', CAST(NULL AS JSON))",
+ "SELECT EMBED(CAST(NULL AS JSON))"
+ ]
+ nullQueries.each { query ->
+ sql_impl(adminConnection, "GRANT USAGE_PRIV ON RESOURCE
'${resourceName}' TO '${userName}'")
+ sql query
+ sql query
+ explain {
+ sql "physical plan ${query}"
+ notContains "PhysicalSqlCache"
+ }
+ sql_impl(adminConnection, "REVOKE USAGE_PRIV ON RESOURCE
'${resourceName}' FROM '${userName}'")
+ test {
+ sql query
+ exception "privilege(s) on resource ${resourceName}"
+ }
+ test {
+ sql "EXPLAIN ${query}"
+ exception "privilege(s) on resource ${resourceName}"
+ }
+ }
+ }
+ } finally {
+ sql "DROP USER IF EXISTS '${userName}'"
+ sql "DROP RESOURCE IF EXISTS '${resourceName}'"
+ }
+}
diff --git a/regression-test/suites/ai_p0/test_create_ai_resource.groovy
b/regression-test/suites/ai_p0/test_create_ai_resource.groovy
index d9e9522ab6b..277265774dd 100644
--- a/regression-test/suites/ai_p0/test_create_ai_resource.groovy
+++ b/regression-test/suites/ai_p0/test_create_ai_resource.groovy
@@ -76,21 +76,112 @@ suite("test_create_ai_resource") {
exception "Missing [ai.api_key] in properties for provider: DEEPSEEK"
}
+ for (String key : ['ai.api_key', 'ai.max_retries', 'ai.anthropic_version',
'ai.effort']) {
+ for (String value : ['', ' ']) {
+ test {
+ sql """CREATE RESOURCE "${resourceName}" PROPERTIES(
+ 'type' = 'ai',
+ 'ai.provider_type' = 'local',
+ 'ai.endpoint' =
'http://localhost:8000/v1/chat/completions',
+ 'ai.model_name' = 'chat-model',
+ '${key}' = '${value}'
+ );"""
+ exception "[${key}] must not be empty"
+ }
+ }
+ }
+
+ def invalidNumericProperties = [
+ ['ai.temperature', 'NaN'],
+ ['ai.temperature', 'Infinity'],
+ ['ai.temperature', '-Infinity'],
+ ['ai.temperature', '-0.1'],
+ ['ai.temperature', '1.1'],
+ ['ai.max_token', '0'],
+ ['ai.max_token', '-2'],
+ ['ai.max_token', '9223372036854775808'],
+ ['ai.max_retries', '-1'],
+ ['ai.max_retries', '2147483648'],
+ ['ai.retry_delay_second', '-1'],
+ ['ai.retry_delay_second', '2147483648'],
+ ['ai.dimensions', '0'],
+ ['ai.dimensions', '-2'],
+ ['ai.dimensions', '2147483648']
+ ]
+ for (def entry : invalidNumericProperties) {
+ test {
+ sql """CREATE RESOURCE "${resourceName}" PROPERTIES(
+ 'type' = 'ai',
+ 'ai.provider_type' = 'local',
+ 'ai.endpoint' = 'http://localhost:8000/v1/chat/completions',
+ 'ai.model_name' = 'chat-model',
+ '${entry[0]}' = '${entry[1]}'
+ );"""
+ exception "Failed to set property '${entry[0]}'"
+ }
+ }
+
sql """CREATE RESOURCE IF NOT EXISTS "${resourceName}"
PROPERTIES(
'type' = 'ai',
- 'ai.provider_type' = 'deepseek',
- 'ai.endpoint' = 'https://api.deepseek.com/chat/completions',
- 'ai.model_name' = 'deepseek-chat',
- 'ai.api_key' = 'sk-xxx',
- 'ai.temperature' = '0.7',
- 'ai.max_token' = '1024',
- 'ai.max_retries' = '3',
- 'ai.retry_delay_second' = '1',
+ 'ai.provider_type' = ' deepseek ',
+ 'ai.endpoint' = ' https://api.deepseek.com/chat/completions ',
+ 'ai.model_name' = ' deepseek-chat ',
+ 'ai.api_key' = ' sk-xxx ',
+ 'ai.temperature' = ' 0.7 ',
+ 'ai.max_token' = ' 1024 ',
+ 'ai.max_retries' = ' 3 ',
+ 'ai.retry_delay_second' = ' 1 ',
+ 'ai.dimensions' = ' 768 ',
+ 'ai.anthropic_version' = ' 2023-06-01 ',
+ 'ai.effort' = ' high ',
+ 'ai.enable_structured_output' = ' true ',
'ai.validity_check' = 'false'
);"""
def res = sql """SHOW RESOURCES WHERE NAME = '${resourceName}'"""
assertTrue(res.size() > 0)
+ def checkStoredProperties = { tag ->
+ quickRunTest(tag, "SHOW RESOURCES WHERE NAME = '${resourceName}'",
true) { row ->
+ [row[2], row[2] in ['id', 'version'] ? '<generated>' : row[3]]
+ }
+ }
+ checkStoredProperties("normalized_create")
+
+ for (String key : ['ai.model_name', 'ai.api_key', 'ai.max_retries',
'ai.anthropic_version', 'ai.effort']) {
+ for (String value : ['', ' ']) {
+ test {
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES('${key}' =
'${value}');"""
+ exception "[${key}] must not be empty"
+ }
+ }
+ }
+
+ for (def entry : invalidNumericProperties) {
+ test {
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES('${entry[0]}' =
'${entry[1]}');"""
+ exception "Failed to set property '${entry[0]}'"
+ }
+ }
+
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES(
+ 'ai.temperature' = '1', 'ai.max_token' = '9223372036854775807',
+ 'ai.max_retries' = '2147483647', 'ai.retry_delay_second' =
'2147483647',
+ 'ai.dimensions' = '2147483647'
+ );"""
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES(
+ 'ai.temperature' = '0', 'ai.max_token' = '1',
+ 'ai.max_retries' = '0', 'ai.retry_delay_second' = '0', 'ai.dimensions'
= '1'
+ );"""
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES(
+ 'ai.temperature' = '-1', 'ai.max_token' = '-1', 'ai.dimensions' = '-1'
+ );"""
+ sql """ALTER RESOURCE "${resourceName}" PROPERTIES(
+ 'ai.temperature' = ' 0.5 ', 'ai.max_token' = ' 2048 ',
+ 'ai.max_retries' = ' 4 ', 'ai.retry_delay_second' = ' 2 ',
+ 'ai.dimensions' = ' 1024 ', 'ai.model_name' = ' updated-model ',
+ 'ai.enable_structured_output' = ' false '
+ );"""
+ checkStoredProperties("normalized_alter")
try_sql("""DROP RESOURCE '${resourceName}'""")
-}
\ No newline at end of file
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]