From 38dfdc0cd66d683f8308833eed7f9a5a6c7678d7 Mon Sep 17 00:00:00 2001 From: linrrarity Date: Tue, 22 Sep 2026 15:42:23 +0800 Subject: [PATCH] [Enhance](ai_func) Support dedicate embed properties in AI RESOURCE (#67673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue Number: close #xxx Related PR: #xxx Problem Summary: - Add dedicated embedding properties to AI resources: - `ai.embed.endpoint` - `ai.embed.provider_type` - `ai.embed.model_name` - `ai.embed.api_key` - Require at least one complete property group when creating an AI resource: - the general `ai.*` group, or - the `ai.embed.*` group - Use `ai.embed.*` when executing the `EMBED` function if the embed property group is configured. - Fall back to the general `ai.*` properties otherwise. - Mask `ai.embed.api_key` when displaying resource properties. - Keep other AI functions using the general `ai.*` properties. ```text Doris> CREATE RESOURCE 'default_combine_resource' -> PROPERTIES ( -> 'type' = 'ai', -> -> 'ai.provider_type' = 'QWEN', -> 'ai.endpoint' = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', -> 'ai.model_name' = 'qwen-plus', -> 'ai.api_key' = '', -> 'ai.effort' = 'high', -> -> 'ai.embed.provider_type' = 'QWEN', -> 'ai.embed.endpoint' = 'https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings', -> 'ai.embed.model_name' = 'text-embedding-v4', -> 'ai.embed.api_key' = '', -> -> 'ai.embed.mm.provider_type' = 'QWEN', -> 'ai.embed.mm.endpoint' = 'https://dashscope.aliyuncs.com/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding', -> 'ai.embed.mm.model_name' = 'qwen3-vl-embedding', -> 'ai.embed.mm.api_key' = '', -> -> 'ai.dimensions' = '1024' -> ); Query OK, 0 rows affected (0.026 sec) Doris> set default_ai_resource = 'default_combine_resource'; Query OK, 0 rows affected (0.009 sec) Doris> SELECT array_size(EMBED('this is a test')); +-------------------------------------+ | array_size(EMBED('this is a test')) | +-------------------------------------+ | 1024 | +-------------------------------------+ 1 row in set (1.729 sec) Doris> EXPLAIN SELECT array_size(EMBED('this is a test')); +---------------------------------------------------------------------------+ | Explain String(Nereids Planner) | +---------------------------------------------------------------------------+ | PLAN FRAGMENT 0 | | OUTPUT EXPRS: | | array_size(EMBED('this is a test'))[#0] | | PARTITION: UNPARTITIONED | | | | HAS_COLO_PLAN_NODE: false | | | | VRESULT SINK | | MYSQL_PROTOCOL | | | | 0:VUNION(11) | | constant exprs: | | cardinality(embed('default_combine_resource', 'this is a test')) | | | | | | | | ========== STATISTICS ========== | +---------------------------------------------------------------------------+ 17 rows in set (0.030 sec) Doris> SELECT AI_TRANSLATE('this is a test', 'chinese'); +-------------------------------------------+ | AI_TRANSLATE('this is a test', 'chinese') | +-------------------------------------------+ | 这是一个测试 | +-------------------------------------------+ 1 row in set (1.269 sec) Doris> EXPLAIN SELECT AI_TRANSLATE('this is a test', 'chinese'); +--------------------------------------------------------------------------------+ | Explain String(Nereids Planner) | +--------------------------------------------------------------------------------+ | PLAN FRAGMENT 0 | | OUTPUT EXPRS: | | AI_TRANSLATE('this is a test', 'chinese')[#0] | | PARTITION: UNPARTITIONED | | | | HAS_COLO_PLAN_NODE: false | | | | VRESULT SINK | | MYSQL_PROTOCOL | | | | 0:VUNION(11) | | constant exprs: | | ai_translate('default_combine_resource', 'this is a test', 'chinese') | | | | | | | | ========== STATISTICS ========== | +--------------------------------------------------------------------------------+ 17 rows in set (0.010 sec) Doris> EXPLAIN SELECT EMBED( -> CAST( -> '{"content_type":"image/png","uri":"https://example.com/image.png"}' -> AS JSON -> ) -> ); +--------------------------------------------------------------------------------------------------------------------------------+ | Explain String(Nereids Planner) | +--------------------------------------------------------------------------------------------------------------------------------+ | PLAN FRAGMENT 0 | | OUTPUT EXPRS: | | EMBED( | | CAST( | | '{"content_type":"image/png","uri":"https://example.com/image.png"}' | | AS JSON | | ) | | )[#0] | | PARTITION: UNPARTITIONED | | | | HAS_COLO_PLAN_NODE: false | | | | VRESULT SINK | | MYSQL_PROTOCOL | | | | 0:VUNION(11) | | constant exprs: | | embed('default_combine_resource', CAST('{"content_type":"image/png","uri":"https://example.com/image.png"}' AS json)) | | | | | | | | ========== STATISTICS ========== | +--------------------------------------------------------------------------------------------------------------------------------+ ``` None --- be/src/core/string_buffer.hpp | 7 +- be/src/exprs/function/ai/ai_adapter.h | 77 ++- be/src/exprs/function/ai/ai_functions.h | 25 +- be/src/exprs/function/ai/embed.h | 34 +- be/test/ai/ai_adapter_test.cpp | 15 + be/test/ai/ai_function_test.cpp | 12 +- be/test/ai/embed_test.cpp | 148 +++++ be/test/core/string_buffer_test.cpp | 9 + .../org/apache/doris/catalog/AIResource.java | 123 +++-- .../doris/common/util/PrintableMap.java | 3 + .../property/constants/AIProperties.java | 88 ++- .../expressions/functions/agg/AIAgg.java | 4 + .../expressions/functions/ai/AIFunction.java | 4 + .../trees/expressions/functions/ai/Embed.java | 17 +- .../apache/doris/catalog/AIResourceTest.java | 515 ++++++++++++++++++ .../doris/common/util/PrintableMapTest.java | 7 + gensrc/thrift/PaloInternalService.thrift | 15 +- 17 files changed, 1008 insertions(+), 95 deletions(-) diff --git a/be/src/core/string_buffer.hpp b/be/src/core/string_buffer.hpp index 81223f99b0d99f..4f93776ab2a1c2 100644 --- a/be/src/core/string_buffer.hpp +++ b/be/src/core/string_buffer.hpp @@ -227,8 +227,8 @@ using BufferWriter = BufferWritable; // There is consumption of the buffer in the read method. class BufferReadable { public: - explicit BufferReadable(StringRef& ref) : _data(ref.data) {} - explicit BufferReadable(StringRef&& ref) : _data(ref.data) {} + explicit BufferReadable(StringRef& ref) : _data(ref.data), _end(ref.data + ref.size) {} + explicit BufferReadable(StringRef&& ref) : _data(ref.data), _end(ref.data + ref.size) {} ~BufferReadable() = default; StringRef read(size_t len) { @@ -244,6 +244,8 @@ class BufferReadable { const char* data() { return _data; } + bool has_remaining() const { return _data < _end; } + void add_offset(size_t len) { _data += len; } void read_var_uint(UInt64& x) { @@ -316,6 +318,7 @@ class BufferReadable { private: const char* _data; + const char* _end; }; using VectorBufferReader = BufferReadable; diff --git a/be/src/exprs/function/ai/ai_adapter.h b/be/src/exprs/function/ai/ai_adapter.h index bda8e6798b5f27..e06816014ac46d 100644 --- a/be/src/exprs/function/ai/ai_adapter.h +++ b/be/src/exprs/function/ai/ai_adapter.h @@ -43,16 +43,17 @@ namespace doris { struct AIResource { AIResource() = default; AIResource(const TAIResource& tai) - : endpoint(tai.endpoint), - provider_type(tai.provider_type), - model_name(tai.model_name), - api_key(tai.api_key), - temperature(tai.temperature), - max_tokens(tai.max_tokens), - max_retries(tai.max_retries), - retry_delay_second(tai.retry_delay_second), - anthropic_version(tai.anthropic_version), - dimensions(tai.dimensions) {} + : AIResource(tai, tai.endpoint, tai.provider_type, tai.model_name, tai.api_key) {} + + static AIResource from_embed(const TAIResource& tai) { + return AIResource(tai, tai.embed_endpoint, tai.embed_provider_type, tai.embed_model_name, + tai.embed_api_key); + } + + static AIResource from_multimodal_embed(const TAIResource& tai) { + return AIResource(tai, tai.embed_mm_endpoint, tai.embed_mm_provider_type, + tai.embed_mm_model_name, tai.embed_mm_api_key); + } std::string endpoint; std::string provider_type; @@ -64,6 +65,7 @@ struct AIResource { int32_t retry_delay_second; std::string anthropic_version; int32_t dimensions; + std::string effort; void serialize(BufferWritable& buf) const { buf.write_binary(endpoint); @@ -76,6 +78,9 @@ struct AIResource { buf.write_binary(retry_delay_second); buf.write_binary(anthropic_version); buf.write_binary(dimensions); + if (!effort.empty()) { + buf.write_binary(effort); + } } void deserialize(BufferReadable& buf) { @@ -89,7 +94,26 @@ struct AIResource { buf.read_binary(retry_delay_second); buf.read_binary(anthropic_version); buf.read_binary(dimensions); + if (buf.has_remaining()) { + buf.read_binary(effort); + } } + +private: + AIResource(const TAIResource& tai, const std::string& selected_endpoint, + const std::string& selected_provider_type, const std::string& selected_model_name, + const std::string& selected_api_key) + : endpoint(selected_endpoint), + provider_type(selected_provider_type), + model_name(selected_model_name), + api_key(selected_api_key), + temperature(tai.temperature), + max_tokens(tai.max_tokens), + max_retries(tai.max_retries), + retry_delay_second(tai.retry_delay_second), + anthropic_version(tai.anthropic_version), + dimensions(tai.dimensions), + effort(tai.effort) {} }; enum class MultimodalType { IMAGE, VIDEO, AUDIO }; @@ -124,6 +148,8 @@ class AIAdapter { _config.max_retries = config.max_retries; _config.retry_delay_second = config.retry_delay_second; _config.anthropic_version = config.anthropic_version; + _config.dimensions = config.dimensions; + _config.effort = config.effort; } // Build request payload based on input text strings @@ -747,7 +773,8 @@ class OpenAIAdapter : public VoyageAIAdapter { {"role": "user", "content": "xxx"} ], "temperature": 0.7, - "max_output_tokens": 150 + "max_output_tokens": 150, + "reasoning": {"effort": "max"} }*/ doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator); @@ -759,6 +786,12 @@ class OpenAIAdapter : public VoyageAIAdapter { if (_config.max_tokens != -1) { doc.AddMember("max_output_tokens", _config.max_tokens, allocator); } + if (!_config.effort.empty()) { + rapidjson::Value reasoning(rapidjson::kObjectType); + reasoning.AddMember("effort", rapidjson::Value(_config.effort.c_str(), allocator), + allocator); + doc.AddMember("reasoning", reasoning, allocator); + } // input rapidjson::Value input(rapidjson::kArrayType); @@ -784,6 +817,7 @@ class OpenAIAdapter : public VoyageAIAdapter { ], "temperature": x, "max_tokens": x, + "reasoning_effort": "low" }*/ doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator); @@ -795,6 +829,10 @@ class OpenAIAdapter : public VoyageAIAdapter { if (_config.max_tokens != -1) { doc.AddMember("max_tokens", _config.max_tokens, allocator); } + if (!_config.effort.empty()) { + doc.AddMember("reasoning_effort", + rapidjson::Value(_config.effort.c_str(), allocator), allocator); + } rapidjson::Value messages(rapidjson::kArrayType); if (system_prompt && *system_prompt) { @@ -1245,7 +1283,8 @@ class GeminiAdapter : public AIAdapter { ], "generationConfig": { "temperature": 0.7, - "maxOutputTokens": 1024 + "maxOutputTokens": 1024, + "thinkingConfig": {"thinkingLevel": "high"} } }*/ @@ -1283,6 +1322,13 @@ class GeminiAdapter : public AIAdapter { if (_config.max_tokens != -1) { generationConfig.AddMember("maxOutputTokens", _config.max_tokens, allocator); } + if (!_config.effort.empty()) { + rapidjson::Value thinking_config(rapidjson::kObjectType); + thinking_config.AddMember("thinkingLevel", + rapidjson::Value(_config.effort.c_str(), allocator), + allocator); + generationConfig.AddMember("thinkingConfig", thinking_config, allocator); + } doc.AddMember("generationConfig", generationConfig, allocator); rapidjson::StringBuffer buffer; @@ -1561,6 +1607,7 @@ class AnthropicAdapter : public VoyageAIAdapter { /* "model": "claude-opus-4-1-20250805", "max_tokens": 1024, + "output_config": {"effort": "medium"}, "system": "system_prompt here", "messages": [ {"role": "user", "content": "xxx"} @@ -1579,6 +1626,12 @@ class AnthropicAdapter : public VoyageAIAdapter { // Keep the default value, Anthropic requires this parameter doc.AddMember("max_tokens", 2048, allocator); } + if (!_config.effort.empty()) { + rapidjson::Value output_config(rapidjson::kObjectType); + output_config.AddMember("effort", rapidjson::Value(_config.effort.c_str(), allocator), + allocator); + doc.AddMember("output_config", output_config, allocator); + } if (system_prompt && *system_prompt) { doc.AddMember("system", rapidjson::Value(system_prompt, allocator), allocator); } diff --git a/be/src/exprs/function/ai/ai_functions.h b/be/src/exprs/function/ai/ai_functions.h index 6fdd0414e47907..fd2aabdfe34975 100644 --- a/be/src/exprs/function/ai/ai_functions.h +++ b/be/src/exprs/function/ai/ai_functions.h @@ -97,7 +97,7 @@ class AIFunction : public IFunction { return Status::OK(); } - TAIResource config; + AIResource config; std::shared_ptr adapter; if (Status status = this->_init_from_resource(context, block, arguments, config, adapter); !status.ok()) { @@ -131,7 +131,7 @@ class AIFunction : public IFunction { return Status::OK(); } - static void normalize_endpoint(TAIResource& config) { + static void normalize_endpoint(AIResource& config) { // 1. If users configure only the version root like `.../v1` or `.../v1beta`, append // `models/:batchEmbedContents` for `embed`, and `models/:generateContent` // for other AI scalar functions. @@ -172,7 +172,7 @@ class AIFunction : public IFunction { // Executes one HTTP POST request and validates transport-level success. Status do_send_request(HttpClient* client, const std::string& request_body, - std::string& response, const TAIResource& config, + std::string& response, const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { RETURN_IF_ERROR(client->init(config.endpoint, false)); @@ -208,7 +208,7 @@ class AIFunction : public IFunction { // Sends the request with retry mechanism for handling transient failures Status send_request_to_llm(const std::string& request_body, std::string& response, - const TAIResource& config, std::shared_ptr& adapter, + const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { return HttpClient::execute_with_retry(config.max_retries, config.retry_delay_second, [this, &request_body, &response, &config, &adapter, @@ -229,7 +229,7 @@ class AIFunction : public IFunction { // Provider-reusable helper for string-returning functions. // Executes one batch request and parses the provider result into one string per input row. Status execute_batch_request(const std::vector& batch_prompts, - std::vector& results, const TAIResource& config, + std::vector& results, const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { #ifdef BE_TEST @@ -293,7 +293,7 @@ class AIFunction : public IFunction { // Runs the common batch execution flow; derived classes only need to define how one batch of // string results is inserted into the final output column. Status execute(FunctionContext* context, Block& block, const ColumnNumbers& arguments, - uint32_t result, size_t input_rows_count, const TAIResource& config, + uint32_t result, size_t input_rows_count, const AIResource& config, std::shared_ptr& adapter) const { Columns prompt_columns; prompt_columns.reserve(arguments.size() - 1); @@ -413,7 +413,7 @@ class AIFunction : public IFunction { private: // The ai resource must be literal Status _init_from_resource(FunctionContext* context, const Block& block, - const ColumnNumbers& arguments, TAIResource& config, + const ColumnNumbers& arguments, AIResource& config, std::shared_ptr& adapter) const { const ColumnWithTypeAndName& resource_column = block.get_by_position(arguments[0]); StringRef resource_name_ref = resource_column.column->get_data_at(0); @@ -424,7 +424,12 @@ class AIFunction : public IFunction { DORIS_CHECK(ai_resources); auto it = ai_resources->find(resource_name); DORIS_CHECK(it != ai_resources->end()); - config = it->second; + PrimitiveType input_type = INVALID_TYPE; + if (arguments.size() > 1) { + input_type = + remove_nullable(block.get_by_position(arguments[1]).type)->get_primitive_type(); + } + config = assert_cast(*this).select_ai_resource(it->second, input_type); normalize_endpoint(config); @@ -435,6 +440,10 @@ class AIFunction : public IFunction { return Status::OK(); } + AIResource select_ai_resource(const TAIResource& resource, PrimitiveType /*input_type*/) const { + return AIResource(resource); + } + // Serializes one text batch into the shared JSON-array prompt format consumed by LLM // providers for batch string functions. Status build_batch_prompt(const std::vector& batch_prompts, diff --git a/be/src/exprs/function/ai/embed.h b/be/src/exprs/function/ai/embed.h index f193a1c171c1b5..7ed1665b2d5fcf 100644 --- a/be/src/exprs/function/ai/embed.h +++ b/be/src/exprs/function/ai/embed.h @@ -44,8 +44,22 @@ class FunctionEmbed : public AIFunction { using PreparedFunctionImpl::execute; + AIResource select_ai_resource(const TAIResource& resource, PrimitiveType input_type) const { + bool has_complete_multimodal_embed_properties = _has_complete_resource_properties( + resource.embed_mm_endpoint, resource.embed_mm_provider_type, + resource.embed_mm_model_name, resource.embed_mm_api_key); + if (input_type == PrimitiveType::TYPE_JSONB && has_complete_multimodal_embed_properties) { + return AIResource::from_multimodal_embed(resource); + } + bool has_complete_embed_properties = _has_complete_resource_properties( + resource.embed_endpoint, resource.embed_provider_type, resource.embed_model_name, + resource.embed_api_key); + return has_complete_embed_properties ? AIResource::from_embed(resource) + : AIResource(resource); + } + Status execute(FunctionContext* context, Block& block, const ColumnNumbers& arguments, - uint32_t result, size_t input_rows_count, const TAIResource& config, + uint32_t result, size_t input_rows_count, const AIResource& config, std::shared_ptr& adapter) const { if (arguments.size() != 2) { return Status::InvalidArgument("Function EMBED expects 2 arguments, but got {}", @@ -90,6 +104,14 @@ class FunctionEmbed : public AIFunction { static FunctionPtr create() { return std::make_shared(); } private: + static bool _has_complete_resource_properties(std::string_view endpoint, + std::string_view provider_type, + std::string_view model_name, + std::string_view api_key) { + return !endpoint.empty() && !provider_type.empty() && !model_name.empty() && + (provider_type == "LOCAL" || !api_key.empty()); + } + static int32_t _get_embed_max_batch_size(FunctionContext* context) { QueryContext* query_ctx = context->state()->get_query_ctx(); DORIS_CHECK(query_ctx != nullptr); @@ -98,7 +120,7 @@ class FunctionEmbed : public AIFunction { } Status _execute_text_embed(FunctionContext* context, Block& block, uint32_t result, - size_t input_rows_count, const TAIResource& config, + size_t input_rows_count, const AIResource& config, std::shared_ptr& adapter, const ColumnPtr& input_column, ColumnUInt8::MutablePtr result_null_map) const { auto col_result = ColumnArray::create( @@ -155,7 +177,7 @@ class FunctionEmbed : public AIFunction { } Status _execute_multimodal_embed(FunctionContext* context, Block& block, uint32_t result, - size_t input_rows_count, const TAIResource& config, + size_t input_rows_count, const AIResource& config, std::shared_ptr& adapter, const ColumnPtr& input_column, ColumnUInt8::MutablePtr result_null_map) const { @@ -218,7 +240,7 @@ class FunctionEmbed : public AIFunction { // Sends one embedding request with a prebuilt request body and validates returned row count. Status _execute_prebuilt_embedding_request(const std::string& request_body, std::vector>& results, - size_t expected_size, const TAIResource& config, + size_t expected_size, const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { std::string response; @@ -251,7 +273,7 @@ class FunctionEmbed : public AIFunction { // EMBED-private helper. // Flushes one accumulated text embedding batch into the output array column. Status _flush_text_embedding_batch(std::vector& batch_prompts, - ColumnArray& col_result, const TAIResource& config, + ColumnArray& col_result, const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { if (batch_prompts.empty()) { @@ -275,7 +297,7 @@ class FunctionEmbed : public AIFunction { Status _flush_multimodal_embedding_batch(std::vector& batch_media_types, std::vector& batch_media_content_types, std::vector& batch_media_urls, - ColumnArray& col_result, const TAIResource& config, + ColumnArray& col_result, const AIResource& config, std::shared_ptr& adapter, FunctionContext* context) const { if (batch_media_urls.empty()) { diff --git a/be/test/ai/ai_adapter_test.cpp b/be/test/ai/ai_adapter_test.cpp index da40ef217dc94a..b16efbce449e48 100644 --- a/be/test/ai/ai_adapter_test.cpp +++ b/be/test/ai/ai_adapter_test.cpp @@ -248,6 +248,7 @@ TEST(AI_ADAPTER_TEST, openai_adapter_completions_request) { config.temperature = 0.5; config.max_tokens = 64; config.api_key = "test_openai_key"; + config.effort = "low"; adapter.init(config); // header @@ -284,6 +285,8 @@ TEST(AI_ADAPTER_TEST, openai_adapter_completions_request) { ASSERT_TRUE(doc.HasMember("max_tokens")) << "Missing max_tokens field"; ASSERT_TRUE(doc["max_tokens"].IsInt()) << "Max_tokens field is not an integer"; ASSERT_EQ(doc["max_tokens"].GetInt(), 64); + ASSERT_TRUE(doc.HasMember("reasoning_effort")); + ASSERT_STREQ(doc["reasoning_effort"].GetString(), "low"); // msg ASSERT_TRUE(doc.HasMember("messages")) << "Missing messages field"; ASSERT_TRUE(doc["messages"].IsArray()) << "Messages is not an array"; @@ -321,6 +324,7 @@ TEST(AI_ADAPTER_TEST, openai_adatper_responses_request) { config.max_tokens = 64; config.api_key = "test_openai_key"; config.endpoint = "https://api.openai.com/v1/responses"; + config.effort = "max"; adapter.init(config); // header @@ -357,6 +361,9 @@ TEST(AI_ADAPTER_TEST, openai_adatper_responses_request) { ASSERT_TRUE(doc.HasMember("max_output_tokens")) << "Missing max_output_tokens field"; ASSERT_TRUE(doc["max_output_tokens"].IsInt()) << "max_output_tokens field is not an integer"; ASSERT_EQ(doc["max_output_tokens"].GetInt(), 64); + ASSERT_TRUE(doc.HasMember("reasoning")); + ASSERT_TRUE(doc["reasoning"].IsObject()); + ASSERT_STREQ(doc["reasoning"]["effort"].GetString(), "max"); // input ASSERT_TRUE(doc.HasMember("input")) << "Missing input field"; @@ -671,6 +678,7 @@ TEST(AI_ADAPTER_TEST, gemini_adapter_request) { config.temperature = 0.2; config.max_tokens = 32; config.api_key = "test_gemini_key"; + config.effort = "high"; adapter.init(config); // header test @@ -705,6 +713,9 @@ TEST(AI_ADAPTER_TEST, gemini_adapter_request) { ASSERT_TRUE(gen_cfg.HasMember("maxOutputTokens")) << "Missing maxOutputTokens field"; ASSERT_TRUE(gen_cfg["maxOutputTokens"].IsInt()); ASSERT_EQ(gen_cfg["maxOutputTokens"].GetInt(), 32); + ASSERT_TRUE(gen_cfg.HasMember("thinkingConfig")); + ASSERT_TRUE(gen_cfg["thinkingConfig"].IsObject()); + ASSERT_STREQ(gen_cfg["thinkingConfig"]["thinkingLevel"].GetString(), "high"); // system_prompt ASSERT_TRUE(doc.HasMember("systemInstruction")) << "Missing system field"; @@ -753,6 +764,7 @@ TEST(AI_ADAPTER_TEST, anthropic_adapter_request) { config.max_tokens = 256; config.api_key = "test_anthropic_key"; config.anthropic_version = "2023-06-01"; + config.effort = "medium"; adapter.init(config); // header @@ -790,6 +802,9 @@ TEST(AI_ADAPTER_TEST, anthropic_adapter_request) { ASSERT_TRUE(doc.HasMember("max_tokens")) << "Missing max_tokens field"; ASSERT_TRUE(doc["max_tokens"].IsInt()) << "Max_tokens field is not an integer"; ASSERT_EQ(doc["max_tokens"].GetInt(), 256); + ASSERT_TRUE(doc.HasMember("output_config")); + ASSERT_TRUE(doc["output_config"].IsObject()); + ASSERT_STREQ(doc["output_config"]["effort"].GetString(), "medium"); // system_prompt ASSERT_TRUE(doc.HasMember("system")) << "Missing system field"; diff --git a/be/test/ai/ai_function_test.cpp b/be/test/ai/ai_function_test.cpp index 976eaddd5bcb40..40db74c13447d4 100644 --- a/be/test/ai/ai_function_test.cpp +++ b/be/test/ai/ai_function_test.cpp @@ -1558,14 +1558,14 @@ class FunctionEmbedTestHelper : public FunctionEmbed { }; TEST(AIFunctionTest, NormalizeLegacyCompletionsEndpoint) { - TAIResource resource; + AIResource resource; resource.endpoint = "https://api.openai.com/v1/completions"; FunctionAISentimentTestHelper::normalize_endpoint(resource); ASSERT_EQ(resource.endpoint, "https://api.openai.com/v1/chat/completions"); } TEST(AIFunctionTest, NormalizeEndpointNoopForOtherPaths) { - TAIResource resource; + AIResource resource; resource.endpoint = "https://api.openai.com/v1/chat/completions"; FunctionAISentimentTestHelper::normalize_endpoint(resource); ASSERT_EQ(resource.endpoint, "https://api.openai.com/v1/chat/completions"); @@ -1576,7 +1576,7 @@ TEST(AIFunctionTest, NormalizeEndpointNoopForOtherPaths) { } TEST(AIFunctionTest, NormalizeGeminiGenerateEndpointFromBaseVersion) { - TAIResource resource; + AIResource resource; resource.provider_type = "gemini"; resource.model_name = "gemini-pro"; resource.endpoint = "https://generativelanguage.googleapis.com/v1beta"; @@ -1587,7 +1587,7 @@ TEST(AIFunctionTest, NormalizeGeminiGenerateEndpointFromBaseVersion) { } TEST(AIFunctionTest, NormalizeGeminiEmbedEndpointFromBaseVersion) { - TAIResource resource; + AIResource resource; resource.provider_type = "GEMINI"; resource.model_name = "gemini-embedding-2-preview"; resource.endpoint = "https://generativelanguage.googleapis.com/v1beta"; @@ -1599,7 +1599,7 @@ TEST(AIFunctionTest, NormalizeGeminiEmbedEndpointFromBaseVersion) { } TEST(AIFunctionTest, NormalizeGeminiEndpointNoopForNonBasePath) { - TAIResource resource; + AIResource resource; resource.provider_type = "gemini"; resource.model_name = "gemini-pro"; resource.endpoint = @@ -1611,7 +1611,7 @@ TEST(AIFunctionTest, NormalizeGeminiEndpointNoopForNonBasePath) { } TEST(AIFunctionTest, NormalizeGeminiEmbedLegacySingleEndpointToBatchEndpoint) { - TAIResource resource; + AIResource resource; resource.provider_type = "gemini"; resource.model_name = "gemini-embedding-2-preview"; resource.endpoint = diff --git a/be/test/ai/embed_test.cpp b/be/test/ai/embed_test.cpp index c9bd32ed17cb7a..29c92c9660ff6f 100644 --- a/be/test/ai/embed_test.cpp +++ b/be/test/ai/embed_test.cpp @@ -281,6 +281,154 @@ TEST(EMBED_TEST, embed_function_test) { } } +TEST(EMBED_TEST, prefer_embed_resource_properties) { + TQueryOptions query_options = create_fake_query_options(); + auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options); + + TAIResource ai_resource; + ai_resource.__set_endpoint("invalid://general-endpoint"); + ai_resource.__set_provider_type("OPENAI"); + ai_resource.__set_model_name("general-model"); + ai_resource.__set_api_key("general-api-key"); + ai_resource.__set_embed_endpoint("http://localhost"); + ai_resource.__set_embed_provider_type("MOCK"); + ai_resource.__set_embed_model_name("embed-model"); + ai_resource.__set_embed_api_key("embed-api-key"); + ai_resource.__set_temperature(0.5); + ai_resource.__set_max_tokens(16); + ai_resource.__set_max_retries(1); + ai_resource.__set_retry_delay_second(1); + ai_resource.__set_dimensions(514); + query_ctx->set_ai_resources( + std::map {{"embed_resource", ai_resource}}); + + TQueryGlobals query_globals; + RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr, + query_ctx.get()); + auto ctx = FunctionContext::create_context(&runtime_state, {}, {}); + + auto col_resource = ColumnHelper::create_column( + std::vector {"embed_resource"}); + auto col_text = + ColumnHelper::create_column(std::vector {"test input"}); + + Block block; + block.insert({std::move(col_resource), std::make_shared(), "resource"}); + block.insert({std::move(col_text), std::make_shared(), "text"}); + block.insert( + {nullptr, + std::make_shared(make_nullable(std::make_shared())), + "result"}); + + auto embed_func = FunctionEmbed::create(); + Status exec_status = embed_func->execute_impl(ctx.get(), block, {0, 1}, 2, 1); + + ASSERT_TRUE(exec_status.ok()) << exec_status.to_string(); +} + +TEST(EMBED_TEST, select_resource_properties_by_input_type) { + TAIResource ai_resource; + ai_resource.__set_endpoint("general-endpoint"); + ai_resource.__set_provider_type("OPENAI"); + ai_resource.__set_model_name("general-model"); + ai_resource.__set_api_key("general-api-key"); + ai_resource.__set_embed_endpoint("embed-endpoint"); + ai_resource.__set_embed_provider_type("QWEN"); + ai_resource.__set_embed_model_name("embed-model"); + ai_resource.__set_embed_api_key("embed-api-key"); + ai_resource.__set_embed_mm_endpoint("multimodal-endpoint"); + ai_resource.__set_embed_mm_provider_type("GEMINI"); + ai_resource.__set_embed_mm_model_name("multimodal-model"); + ai_resource.__set_embed_mm_api_key("multimodal-api-key"); + + FunctionEmbed embed_function; + AIResource text_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_STRING); + EXPECT_EQ(text_resource.endpoint, "embed-endpoint"); + EXPECT_EQ(text_resource.provider_type, "QWEN"); + + AIResource multimodal_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_JSONB); + EXPECT_EQ(multimodal_resource.endpoint, "multimodal-endpoint"); + EXPECT_EQ(multimodal_resource.provider_type, "GEMINI"); +} + +TEST(EMBED_TEST, multimodal_resource_fallback) { + TAIResource ai_resource; + ai_resource.__set_endpoint("general-endpoint"); + ai_resource.__set_provider_type("OPENAI"); + ai_resource.__set_model_name("general-model"); + ai_resource.__set_api_key("general-api-key"); + ai_resource.__set_embed_endpoint("embed-endpoint"); + ai_resource.__set_embed_provider_type("QWEN"); + ai_resource.__set_embed_model_name("embed-model"); + ai_resource.__set_embed_api_key("embed-api-key"); + + FunctionEmbed embed_function; + AIResource embed_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_JSONB); + EXPECT_EQ(embed_resource.endpoint, "embed-endpoint"); + + TAIResource general_resource; + general_resource.__set_endpoint("general-endpoint"); + general_resource.__set_provider_type("OPENAI"); + general_resource.__set_model_name("general-model"); + general_resource.__set_api_key("general-api-key"); + AIResource fallback_resource = + embed_function.select_ai_resource(general_resource, PrimitiveType::TYPE_JSONB); + EXPECT_EQ(fallback_resource.endpoint, "general-endpoint"); + + general_resource.__set_embed_mm_endpoint("multimodal-endpoint"); + general_resource.__set_embed_mm_provider_type("GEMINI"); + general_resource.__set_embed_mm_model_name("multimodal-model"); + general_resource.__set_embed_mm_api_key("multimodal-api-key"); + AIResource text_resource = + embed_function.select_ai_resource(general_resource, PrimitiveType::TYPE_STRING); + EXPECT_EQ(text_resource.endpoint, "general-endpoint"); +} + +TEST(EMBED_TEST, incomplete_dedicated_resource_fallback) { + TAIResource ai_resource; + ai_resource.__set_endpoint("general-endpoint"); + ai_resource.__set_provider_type("OPENAI"); + ai_resource.__set_model_name("general-model"); + ai_resource.__set_api_key("general-api-key"); + ai_resource.__set_embed_endpoint("incomplete-embed-endpoint"); + + FunctionEmbed embed_function; + AIResource text_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_STRING); + EXPECT_EQ(text_resource.endpoint, "general-endpoint"); + EXPECT_EQ(text_resource.provider_type, "OPENAI"); + + ai_resource.__set_embed_provider_type("QWEN"); + ai_resource.__set_embed_model_name("embed-model"); + ai_resource.__set_embed_api_key("embed-api-key"); + ai_resource.__set_embed_mm_endpoint("incomplete-multimodal-endpoint"); + AIResource multimodal_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_JSONB); + EXPECT_EQ(multimodal_resource.endpoint, "incomplete-embed-endpoint"); + EXPECT_EQ(multimodal_resource.provider_type, "QWEN"); +} + +TEST(EMBED_TEST, empty_dedicated_resource_properties_fallback) { + TAIResource ai_resource; + ai_resource.__set_endpoint("general-endpoint"); + ai_resource.__set_provider_type("OPENAI"); + ai_resource.__set_model_name("general-model"); + ai_resource.__set_api_key("general-api-key"); + ai_resource.__set_embed_endpoint(""); + ai_resource.__set_embed_provider_type(""); + ai_resource.__set_embed_model_name(""); + ai_resource.__set_embed_api_key(""); + + FunctionEmbed embed_function; + AIResource text_resource = + embed_function.select_ai_resource(ai_resource, PrimitiveType::TYPE_STRING); + EXPECT_EQ(text_resource.endpoint, "general-endpoint"); + EXPECT_EQ(text_resource.provider_type, "OPENAI"); +} + TEST(EMBED_TEST, embed_function_text_multi_rows) { auto runtime_state = std::make_unique(); auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {}); diff --git a/be/test/core/string_buffer_test.cpp b/be/test/core/string_buffer_test.cpp index 926e61ca721589..7f230ab6c86dbd 100644 --- a/be/test/core/string_buffer_test.cpp +++ b/be/test/core/string_buffer_test.cpp @@ -118,6 +118,15 @@ TEST(StringBufferTest, TestWriteReadBinary) { ASSERT_EQ(str_ref_val.to_string(), read_str_ref_val.to_string()); } +TEST(StringBufferTest, HasRemaining) { + StringRef ref("doris", 5); + BufferReadable reader(ref); + + EXPECT_TRUE(reader.has_remaining()); + reader.read(5); + EXPECT_FALSE(reader.has_remaining()); +} + // This test may fail due to a bug in read_var_uint, where it can read out of bounds. // The loop condition `i < 9` should probably be `i < len`. //TEST(StringBufferTest, TestVarUInt) { 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 346e58285fd372..91553c0fc4d3f4 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 @@ -23,14 +23,14 @@ 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 org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -54,7 +54,6 @@ */ public class AIResource extends Resource { - private static final Logger LOG = LogManager.getLogger(AIResource.class); @SerializedName(value = "properties") private Map properties; @SerializedName(value = "createdByRoot") @@ -83,12 +82,6 @@ protected void setProperties(ImmutableMap newProperties) throws this.properties = Maps.newHashMap(newProperties); AIProperties.requiredAIProperties(properties); - - boolean needCheck = isNeedCheck(properties); - if (LOG.isDebugEnabled()) { - LOG.debug("AI resource need check validity: {}", needCheck); - } - AIProperties.optionalAIProperties(this.properties); } @@ -96,43 +89,54 @@ public String getProperty(String propertyKey) { return properties.get(propertyKey); } - private boolean isNeedCheck(Map newProperties) { - boolean needCheck = !this.properties.containsKey(AIProperties.VALIDITY_CHECK) - || Boolean.parseBoolean(this.properties.get(AIProperties.VALIDITY_CHECK)); + 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))); + } - if (newProperties != null && newProperties.containsKey(AIProperties.VALIDITY_CHECK)) { - needCheck = Boolean.parseBoolean(newProperties.get(AIProperties.VALIDITY_CHECK)); - } + public boolean hasCompleteEmbedProperties() { + return hasCompleteProperties(AIProperties.EMBED_REQUIRED_FIELDS, + AIProperties.EMBED_PROVIDER_TYPE, AIProperties.EMBED_API_KEY); + } - if ("LOCAL".equalsIgnoreCase(this.properties.getOrDefault(AIProperties.PROVIDER_TYPE, ""))) { - needCheck = false; - } - return needCheck; + public boolean hasCompleteMultimodalEmbedProperties() { + return hasCompleteProperties(AIProperties.MULTIMODAL_EMBED_REQUIRED_FIELDS, + AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE, AIProperties.MULTIMODAL_EMBED_API_KEY); + } + + private boolean hasCompleteProperties(List 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))); } @Override public void modifyProperties(Map properties) throws DdlException { - boolean needCheck = isNeedCheck(properties); - if (LOG.isDebugEnabled()) { - LOG.debug("AI resource need check validity: {}", needCheck); - } - - if (needCheck) { + writeLock(); + try { Map changedProperties = new HashMap<>(this.properties); - changedProperties.putAll(properties); + for (Map.Entry 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); - } - // modify properties - writeLock(); - for (Map.Entry kv : properties.entrySet()) { - replaceIfEffectiveValue(this.properties, kv.getKey(), kv.getValue()); - if (kv.getKey().equals(AIProperties.API_KEY)) { - this.properties.put(kv.getKey(), kv.getValue()); - } + this.properties = changedProperties; + ++version; + } finally { + writeUnlock(); } - ++version; - writeUnlock(); super.modifyProperties(properties); } @@ -148,7 +152,9 @@ protected void getProcNodeData(BaseProcResult result) { readLock(); result.addRow(Lists.newArrayList(name, lowerCaseType, "version", String.valueOf(version))); for (Map.Entry entry : properties.entrySet()) { - if (entry.getKey().equals(AIProperties.API_KEY)) { + if (entry.getKey().equals(AIProperties.API_KEY) + || entry.getKey().equals(AIProperties.EMBED_API_KEY) + || entry.getKey().equals(AIProperties.MULTIMODAL_EMBED_API_KEY)) { result.addRow(Lists.newArrayList(name, lowerCaseType, entry.getKey(), "******")); } else { result.addRow(Lists.newArrayList(name, lowerCaseType, entry.getKey(), entry.getValue())); @@ -159,10 +165,45 @@ protected void getProcNodeData(BaseProcResult result) { public TAIResource toThrift() throws NumberFormatException { TAIResource tAIResource = new TAIResource(); - tAIResource.setProviderType(properties.get(AIProperties.PROVIDER_TYPE)); - tAIResource.setEndpoint(properties.get(AIProperties.ENDPOINT)); - tAIResource.setApiKey(properties.get(AIProperties.API_KEY)); - tAIResource.setModelName(properties.get(AIProperties.MODEL_NAME)); + 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)); try { diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/util/PrintableMap.java b/fe/fe-core/src/main/java/org/apache/doris/common/util/PrintableMap.java index 6a2a3170cefa54..813fb2b983965f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/util/PrintableMap.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/PrintableMap.java @@ -61,6 +61,9 @@ public class PrintableMap { SENSITIVE_KEY.add("bos_secret_accesskey"); SENSITIVE_KEY.add("jdbc.password"); SENSITIVE_KEY.add("elasticsearch.password"); + SENSITIVE_KEY.add("ai.api_key"); + SENSITIVE_KEY.add("ai.embed.api_key"); + SENSITIVE_KEY.add("ai.embed.mm.api_key"); SENSITIVE_KEY.add("lance.rest.bearer-token"); SENSITIVE_KEY.add("lance.rest.api-key"); SENSITIVE_KEY.addAll(Arrays.asList( 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 6019aaef303268..43890d5110aad6 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 @@ -32,15 +32,24 @@ public class AIProperties extends BaseProperties { public static final String ENDPOINT = "ai.endpoint"; public static final String PROVIDER_TYPE = "ai.provider_type"; public static final String MODEL_NAME = "ai.model_name"; + public static final String EMBED_ENDPOINT = "ai.embed.endpoint"; + public static final String EMBED_PROVIDER_TYPE = "ai.embed.provider_type"; + public static final String EMBED_MODEL_NAME = "ai.embed.model_name"; + public static final String MULTIMODAL_EMBED_ENDPOINT = "ai.embed.mm.endpoint"; + public static final String MULTIMODAL_EMBED_PROVIDER_TYPE = "ai.embed.mm.provider_type"; + public static final String MULTIMODAL_EMBED_MODEL_NAME = "ai.embed.mm.model_name"; // optional public static final String API_KEY = "ai.api_key"; + public static final String EMBED_API_KEY = "ai.embed.api_key"; + public static final String MULTIMODAL_EMBED_API_KEY = "ai.embed.mm.api_key"; public static final String TEMPERATURE = "ai.temperature"; public static final String MAX_TOKEN = "ai.max_token"; public static final String MAX_RETRIES = "ai.max_retries"; public static final String RETRY_DELAY_SECOND = "ai.retry_delay_second"; public static final String ANTHROPIC_VERSION = "ai.anthropic_version"; public static final String DIMENSIONS = "ai.dimensions"; + public static final String EFFORT = "ai.effort"; // default_val public static final String DEFAULT_TEMPERATURE = "-1"; @@ -53,29 +62,54 @@ public class AIProperties extends BaseProperties { public static final String VALIDITY_CHECK = "ai.validity_check"; public static final List REQUIRED_FIELDS = Arrays.asList(ENDPOINT, PROVIDER_TYPE, MODEL_NAME); + public static final List EMBED_REQUIRED_FIELDS = + Arrays.asList(EMBED_ENDPOINT, EMBED_PROVIDER_TYPE, EMBED_MODEL_NAME); + public static final List MULTIMODAL_EMBED_REQUIRED_FIELDS = + Arrays.asList(MULTIMODAL_EMBED_ENDPOINT, MULTIMODAL_EMBED_PROVIDER_TYPE, + MULTIMODAL_EMBED_MODEL_NAME); public static final List PROVIDERS = Arrays.asList("OPENAI", "LOCAL", "GEMINI", "DEEPSEEK", "ANTHROPIC", "MOONSHOT", "QWEN", "MINIMAX", "ZHIPU", "BAICHUAN", "VOYAGEAI", "JINA"); + private static final List ALL_EFFORT_LEVELS = + Arrays.asList("none", "minimal", "low", "medium", "high", "xhigh", "max"); + private static final List ANTHROPIC_EFFORT_LEVELS = + Arrays.asList("low", "medium", "high", "xhigh", "max"); + private static final List GEMINI_EFFORT_LEVELS = + Arrays.asList("minimal", "low", "medium", "high"); public static void requiredAIProperties(Map properties) throws DdlException { - // Check required field - for (String field : REQUIRED_FIELDS) { - if (Strings.isNullOrEmpty(properties.get(field))) { - throw new DdlException("Missing [" + field + "] in properties."); - } + 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."); } - // Check the provider is valid - properties.put(PROVIDER_TYPE, properties.get(PROVIDER_TYPE).toUpperCase()); - if (PROVIDERS.stream().noneMatch(s -> s.equals(properties.get(PROVIDER_TYPE).toUpperCase()))) { - throw new DdlException("Provider must be one of " + PROVIDERS); + 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); } - // Only the 'local' provider can ignore the 'api-key' - if (!"LOCAL".equals(properties.get(AIProperties.PROVIDER_TYPE)) - && Strings.isNullOrEmpty(properties.get(AIProperties.API_KEY))) { - throw new DdlException("Missing [" + API_KEY + "] in properties for provider: " - + properties.get(AIProperties.PROVIDER_TYPE)); + String effort = properties.get(EFFORT); + if (!Strings.isNullOrEmpty(effort)) { + String providerType = properties.get(PROVIDER_TYPE); + List 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 @@ -89,7 +123,7 @@ public static void requiredAIProperties(Map properties) throws D // Check 'dimensions' temp = properties.get(AIProperties.DIMENSIONS); - if (!Strings.isNullOrEmpty(temp) && temp.equals("-1")) { + if (!Strings.isNullOrEmpty(temp) && !temp.equals("-1")) { int tempVal = Integer.parseInt(temp); if (tempVal <= 0) { throw new DdlException("Dimensions must be a positive integer"); @@ -97,6 +131,30 @@ public static void requiredAIProperties(Map properties) throws D } } + private static boolean hasAnyProperty(Map properties, List requiredFields, + String apiKeyField) { + return properties.containsKey(apiKeyField) || requiredFields.stream().anyMatch(properties::containsKey); + } + + private static void validatePropertyGroup(Map properties, List 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 properties) { properties.putIfAbsent(AIProperties.TEMPERATURE, AIProperties.DEFAULT_TEMPERATURE); properties.putIfAbsent(AIProperties.MAX_TOKEN, AIProperties.DEFAULT_MAX_TOKEN); 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 833f1b7db4d364..40238a936ad2ac 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 @@ -90,6 +90,10 @@ public void checkLegalityAfterRewrite() { if (!(resource instanceof AIResource)) { throw new AnalysisException("AI resource '" + resourceName + "' does not exist"); } + if (!((AIResource) resource).hasCompleteGeneralProperties()) { + throw new AnalysisException("AI resource '" + resourceName + + "' does not contain complete general AI properties"); + } Resource.registerUsedAIResourceName(resourceName); } } 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 7399f2a348f68f..22b0bfe825ab04 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 @@ -61,6 +61,10 @@ public void checkLegalityAfterRewrite() { if (!(resource instanceof AIResource)) { throw new AnalysisException("AI resource '" + resourceName + "' does not exist"); } + if (!((AIResource) resource).hasCompleteGeneralProperties()) { + throw new AnalysisException("AI resource '" + resourceName + + "' does not contain complete general AI properties"); + } Resource.registerUsedAIResourceName(resourceName); } } 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 e3e8725856ff5d..e6bb28a82bd884 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 @@ -102,12 +102,17 @@ public void checkLegalityBeforeTypeCoercion() { if (arity() == 2) { String aiResourceName = requireStringLiteral(child(0), "resource name", "AI Function must accept literal for the resource name."); - validateAIResource(aiResourceName); + validateAIResource(aiResourceName, getArgument(1).getDataType().isJsonType()); return; } throw new AnalysisException("Function EMBED only accepts 1 or 2 arguments"); } + @Override + public void checkLegalityAfterRewrite() { + checkLegalityBeforeTypeCoercion(); + } + private static String requireStringLiteral(Expression arg, String argName, String errorMsg) { if (!(arg instanceof StringLikeLiteral)) { throw new AnalysisException(errorMsg); @@ -119,11 +124,19 @@ private static String requireStringLiteral(Expression arg, String argName, Strin return value; } - private static void validateAIResource(String resourceName) { + private static void validateAIResource(String resourceName, boolean isMultimodal) { Resource resource = Env.getCurrentEnv().getResourceMgr().getResource(resourceName); if (!(resource instanceof AIResource)) { throw new AnalysisException("AI resource '" + resourceName + "' does not exist"); } + AIResource aiResource = (AIResource) resource; + boolean hasSupportedProperties = aiResource.hasCompleteEmbedProperties() + || aiResource.hasCompleteGeneralProperties() + || (isMultimodal && aiResource.hasCompleteMultimodalEmbedProperties()); + if (!hasSupportedProperties) { + throw new AnalysisException("AI resource '" + resourceName + + "' does not have properties required by EMBED"); + } Resource.registerUsedAIResourceName(resourceName); } 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 b191c324990362..680c94ba584891 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 @@ -23,10 +23,17 @@ import org.apache.doris.common.FeMetaVersion; import org.apache.doris.common.UserException; import org.apache.doris.common.io.Text; +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.PrivPredicate; +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.trees.expressions.functions.agg.AIAgg; +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.StringLiteral; import org.apache.doris.nereids.trees.plans.commands.CreateResourceCommand; import org.apache.doris.nereids.trees.plans.commands.info.CreateResourceInfo; import org.apache.doris.persist.gson.GsonUtils; @@ -43,14 +50,24 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; public class AIResourceTest { private static final Logger LOG = LogManager.getLogger(AIResourceTest.class); @@ -150,6 +167,371 @@ public void testFromCommand(@Mocked Env env, @Injectable AccessControllerManager Assert.assertEquals(retryDelaySecond, aiResource.getProperty(AIProperties.RETRY_DELAY_SECOND)); } + @Test + public void testEmbedOnlyResource() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.example.com/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "openai"); + properties.put(AIProperties.EMBED_MODEL_NAME, "text-embedding-model"); + properties.put(AIProperties.EMBED_API_KEY, "embed-api-key"); + + AIResource aiResource = new AIResource("embed-only-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals("OPENAI", aiResource.getProperty(AIProperties.EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("https://api.example.com/v1/embeddings", + aiResource.toThrift().getEmbedEndpoint()); + Assertions.assertEquals("OPENAI", aiResource.toThrift().getEmbedProviderType()); + Assertions.assertEquals("text-embedding-model", aiResource.toThrift().getEmbedModelName()); + Assertions.assertEquals("embed-api-key", aiResource.toThrift().getEmbedApiKey()); + Assertions.assertFalse(aiResource.toThrift().isSetEndpoint()); + } + + @Test + public void testMultimodalEmbedOnlyResource() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.MULTIMODAL_EMBED_ENDPOINT, + "https://api.example.com/v1/multimodal-embeddings"); + properties.put(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE, "qwen"); + properties.put(AIProperties.MULTIMODAL_EMBED_MODEL_NAME, "multimodal-embedding-model"); + properties.put(AIProperties.MULTIMODAL_EMBED_API_KEY, "multimodal-embed-api-key"); + + AIResource aiResource = new AIResource("multimodal-embed-only-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals("QWEN", aiResource.getProperty(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("https://api.example.com/v1/multimodal-embeddings", + aiResource.getProperty(AIProperties.MULTIMODAL_EMBED_ENDPOINT)); + Assertions.assertEquals("multimodal-embedding-model", + aiResource.getProperty(AIProperties.MULTIMODAL_EMBED_MODEL_NAME)); + Assertions.assertEquals("multimodal-embed-api-key", + aiResource.getProperty(AIProperties.MULTIMODAL_EMBED_API_KEY)); + Assertions.assertEquals("https://api.example.com/v1/multimodal-embeddings", + aiResource.toThrift().getEmbedMmEndpoint()); + Assertions.assertEquals("QWEN", aiResource.toThrift().getEmbedMmProviderType()); + Assertions.assertEquals("multimodal-embedding-model", aiResource.toThrift().getEmbedMmModelName()); + Assertions.assertEquals("multimodal-embed-api-key", aiResource.toThrift().getEmbedMmApiKey()); + Assertions.assertFalse(aiResource.toThrift().isSetEndpoint()); + Assertions.assertFalse(aiResource.toThrift().isSetEmbedEndpoint()); + + BaseProcResult result = new BaseProcResult(); + aiResource.getProcNodeData(result); + Assertions.assertTrue(result.getRows().stream().anyMatch(row -> + AIProperties.MULTIMODAL_EMBED_API_KEY.equals(row.get(2)) && "******".equals(row.get(3)))); + Assertions.assertFalse(result.getRows().stream().anyMatch(row -> row.contains("multimodal-embed-api-key"))); + } + + @Test + public void testRejectPartialMultimodalEmbedProperties() { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.MULTIMODAL_EMBED_ENDPOINT, + "https://api.example.com/v1/multimodal-embeddings"); + + AIResource aiResource = new AIResource("partial-multimodal-embed-resource"); + DdlException exception = Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + Assertions.assertTrue(exception.getMessage().contains(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE)); + } + + @Test + public void testRejectEmbedOnlyResourceForNonEmbedScalarFunction() throws DdlException { + AIResource aiResource = createEmbedOnlyResource(); + try (MockedStatic 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); + + AISentiment function = new AISentiment(new StringLiteral("embed-only-resource"), + new StringLiteral("text")); + Assertions.assertThrows(AnalysisException.class, function::checkLegalityAfterRewrite); + } + } + + @Test + public void testRejectEmbedOnlyResourceForAiAgg() throws DdlException { + AIResource aiResource = createEmbedOnlyResource(); + try (MockedStatic 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); + + AIAgg function = new AIAgg(new StringLiteral("embed-only-resource"), + new StringLiteral("text"), new StringLiteral("task")); + Assertions.assertThrows(AnalysisException.class, function::checkLegalityAfterRewrite); + } + } + + @Test + public void testAcceptEmbedOnlyResourceForEmbedFunction() throws DdlException { + AIResource aiResource = createEmbedOnlyResource(); + try (MockedStatic 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); + + 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 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); + + 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 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); + + Embed function = new Embed(new StringLiteral("multimodal-embed-only-resource"), + new StringLiteral("hello")); + Assertions.assertThrows(AnalysisException.class, function::checkLegalityBeforeTypeCoercion); + } + } + + private AIResource createEmbedOnlyResource() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.example.com/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "openai"); + properties.put(AIProperties.EMBED_MODEL_NAME, "text-embedding-model"); + properties.put(AIProperties.EMBED_API_KEY, "embed-api-key"); + + AIResource aiResource = new AIResource("embed-only-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + return aiResource; + } + + private AIResource createMultimodalEmbedOnlyResource() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.MULTIMODAL_EMBED_ENDPOINT, + "https://api.example.com/v1/multimodal-embeddings"); + properties.put(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE, "qwen"); + properties.put(AIProperties.MULTIMODAL_EMBED_MODEL_NAME, "multimodal-embedding-model"); + properties.put(AIProperties.MULTIMODAL_EMBED_API_KEY, "multimodal-embed-api-key"); + + AIResource aiResource = new AIResource("multimodal-embed-only-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + return aiResource; + } + + @Test + public void testLocalEmbedResourceWithoutApiKey() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.EMBED_ENDPOINT, "http://localhost:8000/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "local"); + properties.put(AIProperties.EMBED_MODEL_NAME, "local-embedding-model"); + + AIResource aiResource = new AIResource("local-embed-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals("LOCAL", aiResource.getProperty(AIProperties.EMBED_PROVIDER_TYPE)); + } + + @Test + public void testRejectEmbedResourceWithoutApiKey() { + Map properties = new HashMap<>(); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.example.com/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "openai"); + properties.put(AIProperties.EMBED_MODEL_NAME, "text-embedding-model"); + + AIResource aiResource = new AIResource("embed-resource-without-api-key"); + DdlException exception = Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + Assertions.assertTrue(exception.getMessage().contains("ai.embed.api_key")); + } + + @Test + public void testMaskEmbedApiKey() throws DdlException { + Map properties = new HashMap<>(); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.example.com/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "openai"); + properties.put(AIProperties.EMBED_MODEL_NAME, "text-embedding-model"); + properties.put(AIProperties.EMBED_API_KEY, "embed-api-key"); + + AIResource aiResource = new AIResource("embed-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + BaseProcResult result = new BaseProcResult(); + aiResource.getProcNodeData(result); + + Assertions.assertTrue(result.getRows().stream().anyMatch(row -> + AIProperties.EMBED_API_KEY.equals(row.get(2)) && "******".equals(row.get(3)))); + Assertions.assertFalse(result.getRows().stream().anyMatch(row -> row.contains("embed-api-key"))); + } + + @Test + public void testRejectPartialEmbedProperties() throws DdlException { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.example.com/v1/embeddings"); + + AIResource aiResource = new AIResource("partial-embed-resource"); + DdlException exception = Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + Assertions.assertTrue(exception.getMessage().contains("ai.embed.provider_type")); + } + + @Test + public void testRejectResourceWithoutCompletePropertyGroup() { + Map properties = new HashMap<>(); + properties.put("ai.validity_check", "false"); + + AIResource aiResource = new AIResource("empty-ai-resource"); + Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + } + + @Test + public void testRejectInvalidEffort() { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.EFFORT, "invalid"); + + AIResource aiResource = new AIResource("invalid-effort-resource"); + DdlException exception = Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + Assertions.assertTrue(exception.getMessage().contains(AIProperties.EFFORT)); + } + + @Test + public void testOpenAiDeepSeekAndQwenAcceptAllEffortLevels() throws DdlException { + List providers = Arrays.asList("OPENAI", "DEEPSEEK", "QWEN"); + List effortLevels = Arrays.asList("none", "minimal", "low", "medium", "high", "xhigh", "max"); + for (String provider : providers) { + for (String effort : effortLevels) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, provider); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("effort-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals(effort, aiResource.toThrift().getEffort()); + } + } + } + + @Test + public void testAnthropicEffortLevels() throws DdlException { + for (String effort : Arrays.asList("low", "medium", "high", "xhigh", "max")) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, "ANTHROPIC"); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("effort-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals(effort, aiResource.toThrift().getEffort()); + } + + for (String effort : Arrays.asList("none", "minimal")) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, "ANTHROPIC"); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("invalid-anthropic-effort-resource"); + Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + } + } + + @Test + public void testGeminiEffortLevels() throws DdlException { + for (String effort : Arrays.asList("minimal", "low", "medium", "high")) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, "GEMINI"); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("effort-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals(effort, aiResource.toThrift().getEffort()); + } + + for (String effort : Arrays.asList("none", "xhigh", "max")) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, "GEMINI"); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("invalid-gemini-effort-resource"); + Assertions.assertThrows(DdlException.class, + () -> aiResource.setProperties(ImmutableMap.copyOf(properties))); + } + } + + @Test + public void testOtherProvidersAcceptAllEffortLevels() throws DdlException { + List providers = Arrays.asList("LOCAL", "MOONSHOT", "MINIMAX", "ZHIPU", "BAICHUAN", "VOYAGEAI", "JINA"); + List effortLevels = Arrays.asList("none", "minimal", "low", "medium", "high", "xhigh", "max"); + for (String provider : providers) { + for (String effort : effortLevels) { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.PROVIDER_TYPE, provider); + properties.put(AIProperties.EFFORT, effort); + + AIResource aiResource = new AIResource("effort-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + + Assertions.assertEquals(effort, aiResource.toThrift().getEffort()); + } + } + } + + @Test + public void testEmptyEffortIsNotForwarded() throws DdlException { + Map 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()); + } + + @Test + public void testClearEffortOnModifyAndPersistence() throws Exception { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.EFFORT, "high"); + + AIResource aiResource = new AIResource("clear-effort-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + aiResource.modifyProperties(ImmutableMap.of(AIProperties.EFFORT, "")); + + Assertions.assertNull(aiResource.getProperty(AIProperties.EFFORT)); + Assertions.assertFalse(aiResource.toThrift().isSetEffort()); + + 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()); + } + @Test public void testAnthropic(@Mocked Env env, @Injectable AccessControllerManager accessManager) throws UserException { @@ -299,6 +681,139 @@ public void testModifyProperties() throws Exception { Assert.assertEquals("0.9", aiResource.getProperty(AIProperties.TEMPERATURE)); } + @Test + public void testModifyPropertiesPersistsNormalizedProviders() throws Exception { + Map properties = new HashMap<>(aiProperties); + properties.put(AIProperties.VALIDITY_CHECK, "true"); + properties.put(AIProperties.EMBED_ENDPOINT, "https://api.openai.com/v1/embeddings"); + properties.put(AIProperties.EMBED_PROVIDER_TYPE, "openai"); + properties.put(AIProperties.EMBED_MODEL_NAME, "text-embedding-3-small"); + properties.put(AIProperties.EMBED_API_KEY, "embed-api-key"); + properties.put(AIProperties.DIMENSIONS, "8"); + + AIResource aiResource = new AIResource("normalized-provider-resource"); + aiResource.setProperties(ImmutableMap.copyOf(properties)); + aiResource.modifyProperties(ImmutableMap.of( + AIProperties.PROVIDER_TYPE, "openai", + AIProperties.EMBED_PROVIDER_TYPE, "qwen")); + + Assertions.assertEquals("OPENAI", aiResource.getProperty(AIProperties.PROVIDER_TYPE)); + Assertions.assertEquals("QWEN", aiResource.getProperty(AIProperties.EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("OPENAI", aiResource.toThrift().getProviderType()); + Assertions.assertEquals("QWEN", aiResource.toThrift().getEmbedProviderType()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + aiResource.write(new DataOutputStream(bytes)); + AIResource restoredResource = (AIResource) Resource.read( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + + Assertions.assertEquals("OPENAI", restoredResource.getProperty(AIProperties.PROVIDER_TYPE)); + Assertions.assertEquals("QWEN", restoredResource.getProperty(AIProperties.EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("OPENAI", restoredResource.toThrift().getProviderType()); + Assertions.assertEquals("QWEN", restoredResource.toThrift().getEmbedProviderType()); + } + + @Test + public void testModifyPropertiesNormalizesEmbedProviderForLocalResource() throws Exception { + AIResource aiResource = new AIResource("local-resource"); + aiResource.setProperties(ImmutableMap.of( + AIProperties.ENDPOINT, "http://127.0.0.1:8000/v1/chat/completions", + AIProperties.PROVIDER_TYPE, "local", + AIProperties.MODEL_NAME, "local-model", + AIProperties.DIMENSIONS, "8")); + + aiResource.modifyProperties(ImmutableMap.of( + AIProperties.EMBED_ENDPOINT, "http://127.0.0.1:8000/v1/embeddings", + AIProperties.EMBED_PROVIDER_TYPE, "openai", + AIProperties.EMBED_MODEL_NAME, "text-embedding-3-small", + AIProperties.EMBED_API_KEY, "embed-api-key")); + + Assertions.assertEquals("OPENAI", aiResource.getProperty(AIProperties.EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("OPENAI", aiResource.toThrift().getEmbedProviderType()); + } + + @Test + public void testModifyPropertiesNormalizesMultimodalProviderWhenValidityCheckDisabled() + throws Exception { + AIResource aiResource = new AIResource("validity-check-disabled-resource"); + aiProperties.put(AIProperties.DIMENSIONS, "8"); + aiResource.setProperties(ImmutableMap.copyOf(aiProperties)); + + aiResource.modifyProperties(ImmutableMap.of( + AIProperties.MULTIMODAL_EMBED_ENDPOINT, "https://example.com/multimodal-embeddings", + AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE, "qwen", + AIProperties.MULTIMODAL_EMBED_MODEL_NAME, "qwen3-vl-embedding", + AIProperties.MULTIMODAL_EMBED_API_KEY, "multimodal-api-key")); + + Assertions.assertEquals("QWEN", + aiResource.getProperty(AIProperties.MULTIMODAL_EMBED_PROVIDER_TYPE)); + Assertions.assertEquals("QWEN", aiResource.toThrift().getEmbedMmProviderType()); + } + + @Test + public void testConcurrentModifyPropertiesPreservesIndependentUpdates() throws Exception { + AIResource aiResource = new AIResource("concurrent-resource"); + aiProperties.put(AIProperties.DIMENSIONS, "8"); + aiResource.setProperties(ImmutableMap.copyOf(aiProperties)); + + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread updateTemperature = new Thread(() -> modifyPropertiesAfterStart( + aiResource, ImmutableMap.of(AIProperties.TEMPERATURE, "0.8"), ready, start, failure)); + Thread updateMaxToken = new Thread(() -> modifyPropertiesAfterStart( + aiResource, ImmutableMap.of(AIProperties.MAX_TOKEN, "4096"), ready, start, failure)); + + boolean bothWaiting; + aiResource.writeLock(); + try { + updateTemperature.start(); + updateMaxToken.start(); + Assertions.assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + bothWaiting = waitUntilBlocked(updateTemperature, updateMaxToken); + } finally { + aiResource.writeUnlock(); + } + + updateTemperature.join(5000); + updateMaxToken.join(5000); + Assertions.assertTrue(bothWaiting); + Assertions.assertFalse(updateTemperature.isAlive()); + Assertions.assertFalse(updateMaxToken.isAlive()); + Assertions.assertNull(failure.get(), () -> "Concurrent ALTER failed: " + failure.get()); + Assertions.assertEquals("0.8", aiResource.getProperty(AIProperties.TEMPERATURE)); + Assertions.assertEquals("4096", aiResource.getProperty(AIProperties.MAX_TOKEN)); + } + + private static void modifyPropertiesAfterStart(AIResource aiResource, + Map properties, CountDownLatch ready, CountDownLatch start, + AtomicReference failure) { + try { + ready.countDown(); + start.await(); + aiResource.modifyProperties(properties); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + } + + private static boolean waitUntilBlocked(Thread... threads) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (Arrays.stream(threads).allMatch(AIResourceTest::isBlocked)) { + return true; + } + Thread.sleep(10); + } + return false; + } + + private static boolean isBlocked(Thread thread) { + return thread.getState() == Thread.State.WAITING + || thread.getState() == Thread.State.BLOCKED; + } + @Test public void testDifferentProviders() throws DdlException { // 1. OpenAI diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/util/PrintableMapTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/util/PrintableMapTest.java index b260c90ebfb5a7..d59869a20e768e 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/common/util/PrintableMapTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/PrintableMapTest.java @@ -55,6 +55,13 @@ public void testSensitiveKeysContainAliyunDLFProperties() { Assertions.assertTrue(PrintableMap.SENSITIVE_KEY.contains("oss.secret_key")); } + @Test + public void testSensitiveKeysContainAiApiKeys() { + Assertions.assertTrue(PrintableMap.SENSITIVE_KEY.contains("ai.api_key")); + Assertions.assertTrue(PrintableMap.SENSITIVE_KEY.contains("ai.embed.api_key")); + Assertions.assertTrue(PrintableMap.SENSITIVE_KEY.contains("ai.embed.mm.api_key")); + } + @Test public void testBasicConstructor() { Map testMap = new HashMap<>(); diff --git a/gensrc/thrift/PaloInternalService.thrift b/gensrc/thrift/PaloInternalService.thrift index 9e6d34c7c39621..6753dbe2f4c011 100644 --- a/gensrc/thrift/PaloInternalService.thrift +++ b/gensrc/thrift/PaloInternalService.thrift @@ -673,9 +673,9 @@ enum TCompoundType { } struct TAIResource { - 1: required string endpoint - 2: required string provider_type - 3: required string model_name + 1: optional string endpoint + 2: optional string provider_type + 3: optional string model_name 4: optional string api_key 5: optional double temperature 6: optional i64 max_tokens @@ -683,6 +683,15 @@ struct TAIResource { 8: optional i32 retry_delay_second 9: optional string anthropic_version 10: optional i32 dimensions + 11: optional string embed_endpoint + 12: optional string embed_provider_type + 13: optional string embed_model_name + 14: optional string embed_api_key + 15: optional string effort + 16: optional string embed_mm_endpoint + 17: optional string embed_mm_provider_type + 18: optional string embed_mm_model_name + 19: optional string embed_mm_api_key } struct TCondition {