diff --git a/.workbuddy/memory/2026-07-25.md b/.workbuddy/memory/2026-07-25.md new file mode 100644 index 0000000000000..b10fac8d3d5f3 --- /dev/null +++ b/.workbuddy/memory/2026-07-25.md @@ -0,0 +1,34 @@ +# 2026-07-25 + +## PR #152829 分析与修复计划 +- 本仓库即 PR #152829 (elastic/elasticsearch) 的分支 `tencentcloud_inference`:新增 Tencent Cloud AI Gateway (OpenAI 兼容) 到 Inference API,支持 text_embedding / chat_completion / rerank。 +- 代码位于 `x-pack/plugin/inference/.../services/tencentcloud/`。 +- PR 状态:open,mergeable_state=unstable(CI 不稳定)。评审人 jonathan-buttner(search-inference-team),10 条 line-level 评审 + CI 编译/测试失败。 +- 关键待修(已建 10 个 task): + 1. changelog area: Machine Learning → Inference + 2. InferenceNamedWriteablesProvider 无任何 tencentcloud 注册(合并冲突丢失)→ 必须补回 + 3. 用 minimumCompatible(),需新增专属 TransportVersion + 4. 加 TaskType.COMPLETION(供 ESQL) + 5. 删除 *RequestManager,按 OpenAiService.doUnifiedCompletionInfer / ElasticInferenceService ActionCreator 重构 completion + 6. ServiceSettings 改用 ObjectParser(参照 LlamaEmbeddingsServiceSettings) + 7. URL 若固定则硬编码(参照 JinaAIRerankModel) + 8. 澄清 rerank 支持模型范围 + 9. 按 OpenAi 测试范式补齐/修复测试(编译失败 TencentCloudServiceTests:284 unreported exception) + 10. 本地跑 :x-pack:plugin:inference:test + spotless + +## 全量修复执行结果 (2026-07-25) +- **修复完成 16 个文件,109 个测试全部通过,0 失败。** +- 已完成修复项: + 1. ✅ changelog area: `Machine Learning` → `Inference` (docs/changelog/152829.yaml) + 2. ✅ NamedWriteables 注册:InferenceNamedWriteablesProvider.java 新增 addTencentCloudNamedWriteables 方法,注册 5 个 settings (TencentCloudEmbeddingsServiceSettings, TencentCloudEmbeddingsTaskSettings, TencentCloudChatCompletionServiceSettings, TencentCloudRerankServiceSettings, TencentCloudRerankTaskSettings) + 3. ✅ TransportVersion:server/src/main/resources/transport/definitions/referable/ml_inference_tencentcloud_added.csv + upper_bounds/9.6.csv,ID=9479000(避免与 esql_vsr_source_load_profile 的 9459000 冲突)。TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED 常量引用 fromName("ml_inference_tencentcloud_added")。所�� getMinimalSupportedVersion() 统一引用该常量。 + 4. ✅ TaskType.COMPLETION:SUPPORTED_TASK_TYPES 增加 COMPLETION,SUPPORTED_STREAMING_TASKS 增加 COMPLETION,MODEL_CREATORS 中 COMPLETION 复用 TencentCloudChatCompletionModelCreator + 5. ✅ 删除 TencentCloudChatCompletionRequestManager.java;completion 路径改为 visitor 模式 (TencentCloudActionVisitor + TencentCloudActionCreator) + 6. ✅ ServiceSettings → ObjectParser:TencentCloudCommonServiceSettings 新增 declareCommonFields() + CommonSettingsBuilder 接口。TencentCloudEmbeddingsServiceSettings、TencentCloudChatCompletionServiceSettings、TencentCloudRerankServiceSettings 均改用 ObjectParser 解析(参照 LlamaEmbeddingsServiceSettings 模式),保留 ValidationException 向后兼容。已推送。 + 7. ✅ URL → region:确认用户不能自建部署,但需支持不同地域。删除自由 `url` 字段,改为 `region` 字段(默认 "bj"),端点硬编码为 `https://{region}.aisearch.tencentelasticsearch.com/v1/`。15 文件变更,105 测试通过。 + 8. ✅ rerank 模型范围:确认非固定列表(支持多个模型),模型 ID 透传给网关。更新 rerankerWindowSize() 和 MODEL_ID 配置注释说明透传策略。 + 9. ✅ 测试修复:TencentCloudServiceTests.expectedStreamingTasks() 增加 COMPLETION;testSupportedTaskTypes_ContainsExpectedTasks 增加 COMPLETION 校验 + 10. ✅ 全量验证:109 tests, 0 failures across 14 classes + spotlessApply 通过 + +## ObjectParser 迁移 (second commit) +- 4 文件 227 行变更,109 测试全部通过。已推送。 diff --git a/docs/changelog/152829.yaml b/docs/changelog/152829.yaml new file mode 100644 index 0000000000000..e1d9e29254666 --- /dev/null +++ b/docs/changelog/152829.yaml @@ -0,0 +1,5 @@ +pr: 152829 +summary: Add Tencent Cloud inference service +area: Inference +type: feature +issues: [] diff --git a/server/src/main/resources/transport/definitions/referable/ml_inference_tencentcloud_added.csv b/server/src/main/resources/transport/definitions/referable/ml_inference_tencentcloud_added.csv new file mode 100644 index 0000000000000..d2016a701b7db --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/ml_inference_tencentcloud_added.csv @@ -0,0 +1 @@ +9479000 diff --git a/server/src/main/resources/transport/upper_bounds/9.6.csv b/server/src/main/resources/transport/upper_bounds/9.6.csv index f69efda9b6eca..29cab610741dc 100644 --- a/server/src/main/resources/transport/upper_bounds/9.6.csv +++ b/server/src/main/resources/transport/upper_bounds/9.6.csv @@ -1 +1 @@ -esql_double_range_value_holder,9478000 +ml_inference_tencentcloud_added,9479000 diff --git a/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/InferenceGetServicesIT.java b/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/InferenceGetServicesIT.java index 5df3faa7d6139..62ebc4f701443 100644 --- a/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/InferenceGetServicesIT.java +++ b/x-pack/plugin/inference/qa/inference-service-tests/src/javaRestTest/java/org/elasticsearch/xpack/inference/InferenceGetServicesIT.java @@ -81,7 +81,8 @@ public void testGetServicesWithoutTaskType() throws IOException { "text_embedding_test_service", "voyageai", "watsonxai", - "amazon_sagemaker" + "amazon_sagemaker", + "tencentcloud" ).toArray() ) ); @@ -124,7 +125,8 @@ public void testGetServicesWithTextEmbeddingTaskType() throws IOException { "openshift_ai", "text_embedding_test_service", "voyageai", - "watsonxai" + "watsonxai", + "tencentcloud" ).toArray() ) ); @@ -153,7 +155,8 @@ public void testGetServicesWithRerankTaskType() throws IOException { "watsonxai", "hugging_face", "amazon_sagemaker", - "elastic" + "elastic", + "tencentcloud" ).toArray() ) ); @@ -213,7 +216,8 @@ public void testGetServicesWithChatCompletionTaskType() throws IOException { "mistral", "nvidia", "watsonxai", - "amazonbedrock" + "amazonbedrock", + "tencentcloud" ).toArray() ) ); diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java index 25b55466903d8..c19cad137e4ab 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java @@ -75,7 +75,6 @@ import org.elasticsearch.xpack.inference.services.custom.response.RerankResponseParser; import org.elasticsearch.xpack.inference.services.custom.response.SparseEmbeddingResponseParser; import org.elasticsearch.xpack.inference.services.deepseek.DeepSeekServiceSettings; -import org.elasticsearch.xpack.inference.services.elastic.completion.ElasticInferenceServiceChatCompletionTaskSettings; import org.elasticsearch.xpack.inference.services.elastic.completion.ElasticInferenceServiceCompletionServiceSettings; import org.elasticsearch.xpack.inference.services.elastic.denseembeddings.ElasticInferenceServiceDenseEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.elastic.rerank.ElasticInferenceServiceRerankServiceSettings; @@ -137,7 +136,11 @@ import org.elasticsearch.xpack.inference.services.sagemaker.model.SageMakerModel; import org.elasticsearch.xpack.inference.services.sagemaker.schema.SageMakerSchemas; import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; -import org.elasticsearch.xpack.inference.services.settings.ImmutableEmptyTaskSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsTaskSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankTaskSettings; import org.elasticsearch.xpack.inference.services.voyageai.embeddings.VoyageAIEmbeddingsServiceSettings; import org.elasticsearch.xpack.inference.services.voyageai.embeddings.VoyageAIEmbeddingsTaskSettings; import org.elasticsearch.xpack.inference.services.voyageai.rerank.VoyageAIRerankServiceSettings; @@ -162,9 +165,6 @@ public static List getNamedWriteables() { // Empty default task settings namedWriteables.add(new NamedWriteableRegistry.Entry(TaskSettings.class, EmptyTaskSettings.NAME, EmptyTaskSettings::new)); - namedWriteables.add( - new NamedWriteableRegistry.Entry(TaskSettings.class, ImmutableEmptyTaskSettings.NAME, ImmutableEmptyTaskSettings::new) - ); // Empty default secret settings namedWriteables.add(new NamedWriteableRegistry.Entry(SecretSettings.class, EmptySecretSettings.NAME, EmptySecretSettings::new)); @@ -200,6 +200,7 @@ public static List getNamedWriteables() { addNvidiaNamedWriteables(namedWriteables); addFireworksAiNamedWriteables(namedWriteables); addDeepSeekNamedWriteables(namedWriteables); + addTencentCloudNamedWriteables(namedWriteables); addUnifiedNamedWriteables(namedWriteables); @@ -229,6 +230,40 @@ private static void addDeepSeekNamedWriteables(List namedWriteables) { + namedWriteables.add( + new NamedWriteableRegistry.Entry( + ServiceSettings.class, + TencentCloudEmbeddingsServiceSettings.NAME, + TencentCloudEmbeddingsServiceSettings::new + ) + ); + namedWriteables.add( + new NamedWriteableRegistry.Entry( + TaskSettings.class, + TencentCloudEmbeddingsTaskSettings.NAME, + TencentCloudEmbeddingsTaskSettings::new + ) + ); + namedWriteables.add( + new NamedWriteableRegistry.Entry( + ServiceSettings.class, + TencentCloudChatCompletionServiceSettings.NAME, + TencentCloudChatCompletionServiceSettings::new + ) + ); + namedWriteables.add( + new NamedWriteableRegistry.Entry( + ServiceSettings.class, + TencentCloudRerankServiceSettings.NAME, + TencentCloudRerankServiceSettings::new + ) + ); + namedWriteables.add( + new NamedWriteableRegistry.Entry(TaskSettings.class, TencentCloudRerankTaskSettings.NAME, TencentCloudRerankTaskSettings::new) + ); + } + private static void addGroqNamedWriteables(List namedWriteables) { namedWriteables.add( new NamedWriteableRegistry.Entry( @@ -978,13 +1013,6 @@ private static void addElasticNamedWriteables(List ElasticInferenceServiceCompletionServiceSettings::new ) ); - namedWriteables.add( - new NamedWriteableRegistry.Entry( - TaskSettings.class, - ElasticInferenceServiceChatCompletionTaskSettings.NAME, - ElasticInferenceServiceChatCompletionTaskSettings::new - ) - ); // Rerank namedWriteables.add( diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java index 22691af2eb843..55d84318a20b9 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferencePlugin.java @@ -204,6 +204,7 @@ import org.elasticsearch.xpack.inference.services.sagemaker.model.SageMakerConfiguration; import org.elasticsearch.xpack.inference.services.sagemaker.model.SageMakerModelBuilder; import org.elasticsearch.xpack.inference.services.sagemaker.schema.SageMakerSchemas; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import org.elasticsearch.xpack.inference.services.voyageai.VoyageAIService; import org.elasticsearch.xpack.inference.vectors.EmbeddingQueryVectorBuilder; @@ -655,6 +656,7 @@ public List getInferenceServiceFactories() { context -> new JinaAIService(httpFactory.get(), serviceComponents.get(), context), context -> new VoyageAIService(httpFactory.get(), serviceComponents.get(), context), context -> new DeepSeekService(httpFactory.get(), serviceComponents.get(), context), + context -> new TencentCloudService(httpFactory.get(), serviceComponents.get(), context), context -> new LlamaService(httpFactory.get(), serviceComponents.get(), context), context -> new Ai21Service(httpFactory.get(), serviceComponents.get(), context), context -> new OpenShiftAiService(httpFactory.get(), serviceComponents.get(), context), diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettings.java new file mode 100644 index 0000000000000..3cbe55724975f --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettings.java @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.xcontent.AbstractObjectParser; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.FilteredXContentObject; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; + +import java.io.IOException; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Common service settings shared by all TencentCloud task types. + * Contains the required {@code model_id}, an optional {@code region} (defaults to {@code bj}), and rate limit settings. + *

+ * The endpoint URL is not user-configurable; it is always constructed from the region and the task-specific path + * ({@code https://{region}.aisearch.tencentelasticsearch.com/v1/}). + */ +public class TencentCloudCommonServiceSettings extends FilteredXContentObject + implements + ServiceSettings, + TencentCloudRateLimitServiceSettings { + + public static final String NAME = "tencentcloud_service_settings"; + private static final String REGION = "region"; + + // Default rate limit for TencentCloud AI Gateway (see docs). + public static final RateLimitSettings DEFAULT_RATE_LIMIT_SETTINGS = new RateLimitSettings(20); + + /** + * Declares the common TencentCloud service-settings fields ({@code model_id}, {@code region}, {@code rate_limit}, + * and a no-op {@code api_key}) onto the given parser so that every task-specific settings parser can reuse the + * same declaration. The {@code url} field (if present) is silently consumed for backward compatibility with + * persisted configurations. + */ + public static void declareCommonFields( + AbstractObjectParser parser, + RateLimitSettings defaultRateLimit + ) { + parser.declareString((b, v) -> b.setModelId(v), new ParseField(ServiceFields.MODEL_ID)); + parser.declareString((b, v) -> b.setRegion(v), new ParseField(REGION)); + // Consume the legacy url field silently so that persisted configurations from older versions don't fail to parse. + parser.declareString((b, v) -> {}, new ParseField(ServiceFields.URL)); + parser.declareObject( + (b, v) -> b.setRateLimitSettings(v), + (p, c) -> RateLimitSettings.createParser(c == ConfigurationParseContext.PERSISTENT, defaultRateLimit).apply(p, null), + new ParseField(RateLimitSettings.FIELD_NAME) + ); + // api_key appears in the same JSON block as service settings in REST requests; DefaultSecretSettings extracts + // it separately. Declare it here as a no-op so the strict REQUEST parser does not reject it as an unknown field. + parser.declareString((b, v) -> {}, new ParseField(DefaultSecretSettings.API_KEY)); + } + + /** + * Parses common settings from a map using an ObjectParser. + */ + public static TencentCloudCommonServiceSettings fromMap( + Map map, + ConfigurationParseContext context, + ObjectParser parser, + ValidationException validationException + ) { + try (var xParser = XContentHelper.mapToXContentParser(XContentParserConfiguration.EMPTY, map)) { + T builder = parser.apply(xParser, context); + var commonSettings = builder.buildCommon(); + // Validate region in REQUEST context. + if (context == ConfigurationParseContext.REQUEST) { + if (commonSettings.region().isBlank()) { + validationException.addValidationError( + String.format(Locale.ROOT, "[%s] in [%s] must not be empty", REGION, ModelConfigurations.SERVICE_SETTINGS) + ); + return null; + } + } + return commonSettings; + } catch (ElasticsearchParseException e) { + validationException.addValidationError(e.getMessage()); + return null; + } catch (IOException e) { + validationException.addValidationError("Failed to parse TencentCloud service settings: " + e.getMessage()); + return null; + } + } + + /** + * Builder interface for classes that accumulate common TencentCloud settings fields. + */ + public interface CommonSettingsBuilder { + void setModelId(String modelId); + + void setRegion(String region); + + void setRateLimitSettings(RateLimitSettings rateLimitSettings); + + TencentCloudCommonServiceSettings buildCommon(); + } + + // ---- instance fields and methods ---- + + private final String modelId; + private final String region; + private final RateLimitSettings rateLimitSettings; + + public TencentCloudCommonServiceSettings(String modelId, @Nullable String region, @Nullable RateLimitSettings rateLimitSettings) { + this.modelId = Objects.requireNonNull(modelId); + this.region = region != null && region.isBlank() == false ? region : TencentCloudUtils.DEFAULT_REGION; + this.rateLimitSettings = Objects.requireNonNullElse(rateLimitSettings, DEFAULT_RATE_LIMIT_SETTINGS); + } + + public TencentCloudCommonServiceSettings(StreamInput in) throws IOException { + this.modelId = in.readString(); + this.region = in.readString(); + this.rateLimitSettings = new RateLimitSettings(in); + } + + @Nullable + public TencentCloudCommonServiceSettings updateCommonServiceSettings( + Map serviceSettings, + ValidationException validationException + ) { + int initialValidationErrorCount = validationException.validationErrors().size(); + + var extractedRateLimitSettings = RateLimitSettings.of( + serviceSettings, + this.rateLimitSettings, + validationException, + ConfigurationParseContext.REQUEST + ); + if (validationException.validationErrors().size() > initialValidationErrorCount) { + return null; + } + + return new TencentCloudCommonServiceSettings(this.modelId, this.region, extractedRateLimitSettings); + } + + @Override + public String modelId() { + return modelId; + } + + public String region() { + return region; + } + + @Override + public RateLimitSettings rateLimitSettings() { + return rateLimitSettings; + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + toXContentFragment(builder, params); + builder.endObject(); + return builder; + } + + public XContentBuilder toXContentFragment(XContentBuilder builder, Params params) throws IOException { + return toXContentFragmentOfExposedFields(builder, params); + } + + @Override + public XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder, Params params) throws IOException { + builder.field(ServiceFields.MODEL_ID, modelId); + builder.field(REGION, region); + rateLimitSettings.toXContent(builder, params); + return builder; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(modelId); + out.writeString(region); + rateLimitSettings.writeTo(out); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TencentCloudCommonServiceSettings that = (TencentCloudCommonServiceSettings) o; + return Objects.equals(modelId, that.modelId) + && Objects.equals(region, that.region) + && Objects.equals(rateLimitSettings, that.rateLimitSettings); + } + + @Override + public int hashCode() { + return Objects.hash(modelId, region, rateLimitSettings); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudModel.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudModel.java new file mode 100644 index 0000000000000..266c34e733d2c --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudModel.java @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.inference.TaskSettings; +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.services.RateLimitGroupingModel; +import org.elasticsearch.xpack.inference.services.ServiceUtils; +import org.elasticsearch.xpack.inference.services.settings.ApiKeySecrets; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.action.TencentCloudActionVisitor; + +import java.net.URI; +import java.util.Map; +import java.util.Objects; + +/** + * Base class for all TencentCloud models. Holds the API key, the effective request URI, and the rate limit settings. + */ +public abstract class TencentCloudModel extends RateLimitGroupingModel { + + private final SecureString apiKey; + private final TencentCloudRateLimitServiceSettings rateLimitServiceSettings; + private final URI uri; + + public TencentCloudModel( + ModelConfigurations configurations, + ModelSecrets secrets, + @Nullable ApiKeySecrets apiKeySecrets, + TencentCloudRateLimitServiceSettings rateLimitServiceSettings, + URI uri + ) { + super(configurations, secrets); + this.rateLimitServiceSettings = Objects.requireNonNull(rateLimitServiceSettings); + this.apiKey = ServiceUtils.apiKey(apiKeySecrets); + this.uri = uri; + } + + protected TencentCloudModel(TencentCloudModel model, TaskSettings taskSettings) { + super(model, taskSettings); + this.rateLimitServiceSettings = model.rateLimitServiceSettings(); + this.apiKey = model.apiKey(); + this.uri = model.uri(); + } + + protected TencentCloudModel(TencentCloudModel model, ServiceSettings serviceSettings) { + super(model, serviceSettings); + this.rateLimitServiceSettings = model.rateLimitServiceSettings(); + this.apiKey = model.apiKey(); + this.uri = model.uri(); + } + + public SecureString apiKey() { + return apiKey; + } + + public TencentCloudRateLimitServiceSettings rateLimitServiceSettings() { + return rateLimitServiceSettings; + } + + public URI uri() { + return uri; + } + + @Override + public int rateLimitGroupingHash() { + return apiKey().hashCode(); + } + + @Override + public RateLimitSettings rateLimitSettings() { + return rateLimitServiceSettings.rateLimitSettings(); + } + + public abstract ExecutableAction accept(TencentCloudActionVisitor creator, Map taskSettings); +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudRateLimitServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudRateLimitServiceSettings.java new file mode 100644 index 0000000000000..bd1587acbcf78 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudRateLimitServiceSettings.java @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; + +public interface TencentCloudRateLimitServiceSettings { + RateLimitSettings rateLimitSettings(); +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudResponseHandler.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudResponseHandler.java new file mode 100644 index 0000000000000..a036912b92dca --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudResponseHandler.java @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.xpack.inference.external.http.HttpResult; +import org.elasticsearch.xpack.inference.external.http.retry.BaseResponseHandler; +import org.elasticsearch.xpack.inference.external.http.retry.ResponseParser; +import org.elasticsearch.xpack.inference.external.http.retry.RetryException; +import org.elasticsearch.xpack.inference.external.request.OutboundRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.response.TencentCloudErrorResponseEntity; + +/** + * Standard response handler for TencentCloud AI Gateway (embeddings and rerank). + * Chat-completion uses the OpenAI unified handler because the streaming SSE format is OpenAI compatible. + */ +public class TencentCloudResponseHandler extends BaseResponseHandler { + + static final String VALIDATION_ERROR_MESSAGE = "Received an input validation error response"; + static final String PERMISSION_ERROR_MESSAGE = "Permission denied"; + + public TencentCloudResponseHandler(String requestType, ResponseParser parseFunction) { + super(requestType, parseFunction, TencentCloudErrorResponseEntity::fromResponse); + } + + @Override + protected void checkForFailureStatusCode(OutboundRequest outboundRequest, HttpResult result) throws RetryException { + if (result.isSuccessfulResponse()) { + return; + } + + int statusCode = result.response().getStatusLine().getStatusCode(); + if (statusCode == 500) { + throw new RetryException(true, buildError(SERVER_ERROR, outboundRequest, result)); + } else if (statusCode > 500) { + throw new RetryException(false, buildError(SERVER_ERROR, outboundRequest, result)); + } else if (statusCode == 429) { + throw new RetryException(true, buildError(RATE_LIMIT, outboundRequest, result)); + } else if (statusCode == 400 || statusCode == 422) { + throw new RetryException(false, buildError(VALIDATION_ERROR_MESSAGE, outboundRequest, result)); + } else if (statusCode == 401) { + throw new RetryException(false, buildError(AUTHENTICATION, outboundRequest, result)); + } else if (statusCode == 403) { + throw new RetryException(false, buildError(PERMISSION_ERROR_MESSAGE, outboundRequest, result)); + } else if (statusCode >= 300 && statusCode < 400) { + throw new RetryException(false, buildError(REDIRECTION, outboundRequest, result)); + } else { + throw new RetryException(false, buildError(UNSUCCESSFUL, outboundRequest, result)); + } + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudService.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudService.java new file mode 100644 index 0000000000000..5144203e08f63 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudService.java @@ -0,0 +1,317 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.util.LazyInitializable; +import org.elasticsearch.core.TimeValue; +import org.elasticsearch.inference.ChunkInferenceInput; +import org.elasticsearch.inference.ChunkedInference; +import org.elasticsearch.inference.InferenceServiceConfiguration; +import org.elasticsearch.inference.InferenceServiceExtension; +import org.elasticsearch.inference.InferenceServiceResults; +import org.elasticsearch.inference.InputType; +import org.elasticsearch.inference.Model; +import org.elasticsearch.inference.RerankRequest; +import org.elasticsearch.inference.RerankingInferenceService; +import org.elasticsearch.inference.SettingsConfiguration; +import org.elasticsearch.inference.SimilarityMeasure; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.inference.configuration.SettingsConfigurationFieldType; +import org.elasticsearch.xpack.core.inference.chunking.EmbeddingRequestChunker; +import org.elasticsearch.xpack.inference.external.action.SenderExecutableAction; +import org.elasticsearch.xpack.inference.external.http.retry.ResponseHandler; +import org.elasticsearch.xpack.inference.external.http.sender.EmbeddingsInput; +import org.elasticsearch.xpack.inference.external.http.sender.GenericRequestManager; +import org.elasticsearch.xpack.inference.external.http.sender.HttpRequestSender; +import org.elasticsearch.xpack.inference.external.http.sender.InferenceInputs; +import org.elasticsearch.xpack.inference.external.http.sender.UnifiedChatInput; +import org.elasticsearch.xpack.inference.services.ModelCreator; +import org.elasticsearch.xpack.inference.services.SenderService; +import org.elasticsearch.xpack.inference.services.ServiceComponents; +import org.elasticsearch.xpack.inference.services.ServiceUtils; +import org.elasticsearch.xpack.inference.services.openai.OpenAiUnifiedChatCompletionResponseHandler; +import org.elasticsearch.xpack.inference.services.openai.response.OpenAiChatCompletionResponseEntity; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.action.TencentCloudActionCreator; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModelCreator; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModelCreator; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudChatCompletionRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModelCreator; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.elasticsearch.xpack.inference.external.action.ActionUtils.constructFailedToSendRequestMessage; +import static org.elasticsearch.xpack.inference.external.http.sender.QueryAndDocsInputs.fromRerankRequest; +import static org.elasticsearch.xpack.inference.services.ServiceFields.MODEL_ID; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.createInvalidModelException; + +/** + * Inference service integration for the TencentCloud AI Gateway (OpenAI-compatible), supporting {@code text_embedding}, + * {@code completion}, {@code chat_completion}, and {@code rerank} task types. + */ +public class TencentCloudService extends SenderService implements RerankingInferenceService { + + public static final String NAME = "tencentcloud"; + private static final String SERVICE_NAME = "TencentCloud AI Gateway"; + + public static final TransportVersion TENCENT_CLOUD_INFERENCE_SERVICE_ADDED = TransportVersion.fromName( + "ml_inference_tencentcloud_added" + ); + + // Batch limit for embedding chunking. TencentCloud AI Gateway does not document a hard cap; use a conservative value. + private static final int EMBEDDING_MAX_BATCH_SIZE = 32; + + private static final EnumSet SUPPORTED_TASK_TYPES = EnumSet.of( + TaskType.TEXT_EMBEDDING, + TaskType.COMPLETION, + TaskType.CHAT_COMPLETION, + TaskType.RERANK + ); + private static final EnumSet SUPPORTED_STREAMING_TASKS = EnumSet.of(TaskType.COMPLETION, TaskType.CHAT_COMPLETION); + + private static final ResponseHandler UNIFIED_CHAT_COMPLETION_HANDLER = new OpenAiUnifiedChatCompletionResponseHandler( + "tencentcloud chat completion", + OpenAiChatCompletionResponseEntity::fromResponse + ); + + private static final Map> MODEL_CREATORS = initModelCreators(); + + private static Map> initModelCreators() { + var completionCreator = new TencentCloudChatCompletionModelCreator(); + return Map.of( + TaskType.TEXT_EMBEDDING, + new TencentCloudEmbeddingsModelCreator(), + TaskType.COMPLETION, + completionCreator, + TaskType.CHAT_COMPLETION, + completionCreator, + TaskType.RERANK, + new TencentCloudRerankModelCreator() + ); + } + + public TencentCloudService( + HttpRequestSender.Factory factory, + ServiceComponents serviceComponents, + InferenceServiceExtension.InferenceServiceFactoryContext context + ) { + this(factory, serviceComponents, context.clusterService()); + } + + public TencentCloudService(HttpRequestSender.Factory factory, ServiceComponents serviceComponents, ClusterService clusterService) { + super(factory, serviceComponents, clusterService, MODEL_CREATORS); + } + + @Override + public String name() { + return NAME; + } + + @Override + public InferenceServiceConfiguration getConfiguration() { + return Configuration.get(); + } + + @Override + public EnumSet supportedTaskTypes() { + return SUPPORTED_TASK_TYPES; + } + + @Override + public Set supportedStreamingTasks() { + return SUPPORTED_STREAMING_TASKS; + } + + @Override + protected void doInfer( + Model model, + InferenceInputs inputs, + Map taskSettings, + TimeValue timeout, + ActionListener listener + ) { + if (model instanceof TencentCloudModel tencentCloudModel) { + var actionCreator = new TencentCloudActionCreator(getSender(), getServiceComponents()); + var action = tencentCloudModel.accept(actionCreator, taskSettings); + action.execute(inputs, timeout, listener); + return; + } + + listener.onFailure(createInvalidModelException(model)); + } + + @Override + protected void doUnifiedCompletionInfer( + Model model, + UnifiedChatInput inputs, + TimeValue timeout, + ActionListener listener + ) { + if (model instanceof TencentCloudChatCompletionModel chatModel) { + var requestManager = new GenericRequestManager<>( + getServiceComponents().threadPool(), + chatModel, + UNIFIED_CHAT_COMPLETION_HANDLER, + (unifiedChatInput) -> new TencentCloudChatCompletionRequest(unifiedChatInput, chatModel), + UnifiedChatInput.class + ); + var errorMessage = constructFailedToSendRequestMessage("TencentCloud chat completions"); + var action = new SenderExecutableAction(getSender(), requestManager, errorMessage); + action.execute(inputs, timeout, listener); + } else { + listener.onFailure(createInvalidModelException(model)); + } + } + + @Override + protected void doRerankInfer(Model model, RerankRequest request, TimeValue timeout, ActionListener listener) { + if (model instanceof TencentCloudRerankModel rerankModel) { + var actionCreator = new TencentCloudActionCreator(getSender(), getServiceComponents()); + var action = rerankModel.accept(actionCreator, request.taskSettings()); + action.execute(fromRerankRequest(request), timeout, listener); + } else { + listener.onFailure(createInvalidModelException(model)); + } + } + + @Override + protected void doChunkedInfer( + Model model, + List inputs, + Map taskSettings, + InputType inputType, + TimeValue timeout, + ActionListener> listener + ) { + if (model instanceof TencentCloudEmbeddingsModel == false) { + listener.onFailure(createInvalidModelException(model)); + return; + } + + var embeddingsModel = (TencentCloudEmbeddingsModel) model; + var actionCreator = new TencentCloudActionCreator(getSender(), getServiceComponents()); + + List batchedRequests = new EmbeddingRequestChunker<>( + inputs, + EMBEDDING_MAX_BATCH_SIZE, + embeddingsModel.getConfigurations().getChunkingSettings() + ).batchRequestsWithListeners(listener); + + for (var request : batchedRequests) { + var action = embeddingsModel.accept(actionCreator, taskSettings); + action.execute(new EmbeddingsInput(request.batch().inputs(), inputType), timeout, request.listener()); + } + } + + @Override + protected void validateInputType(InputType inputType, Model model, ValidationException validationException) { + // TencentCloud AI Gateway does not distinguish input types; accept any. + } + + @Override + public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { + if (model instanceof TencentCloudEmbeddingsModel embeddingsModel) { + var serviceSettings = embeddingsModel.getServiceSettings(); + var similarity = serviceSettings.similarity() != null ? serviceSettings.similarity() : SimilarityMeasure.DOT_PRODUCT; + + var updatedServiceSettings = serviceSettings.updateEmbeddingDetails(embeddingSize, similarity); + if (updatedServiceSettings.equals(serviceSettings)) { + return model; + } + + return new TencentCloudEmbeddingsModel(embeddingsModel, updatedServiceSettings); + } + throw ServiceUtils.invalidModelTypeForUpdateModelWithEmbeddingDetails(model.getClass()); + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public boolean usesParserForServiceSettings() { + return true; + } + + @Override + public int rerankerWindowSize(String modelId) { + // Reranker models on TencentCloud AI Gateway (e.g. bge-reranker-large, bge-reranker-v2-m3) support up to + // 512-token inputs. Using 1 token ≈ 0.75 words gives roughly 384 words; use a conservative value with headroom. + // This is a static window because the gateway supports multiple models and model IDs are not validated here. + return 350; + } + + public static class Configuration { + public static InferenceServiceConfiguration get() { + return CONFIGURATION.getOrCompute(); + } + + private static final LazyInitializable CONFIGURATION = new LazyInitializable<>( + () -> { + var configurationMap = new HashMap(); + + configurationMap.put( + MODEL_ID, + new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDescription( + "The name of the model to use for the inference task, e.g. bge-m3 (embeddings)," + + " deepseek-v3 (chat/completions), bge-reranker-v2-m3 (rerank)." + + " The gateway supports additional models; check the TencentCloud AI Gateway documentation for the full list." + ) + .setLabel("Model ID") + .setRequired(true) + .setSensitive(false) + .setUpdatable(false) + .setType(SettingsConfigurationFieldType.STRING) + .build() + ); + + configurationMap.put( + "region", + new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDefaultValue("bj") + .setDescription( + "The TencentCloud AI Gateway region, e.g. bj, sh, gz. " + + "The endpoint URL is constructed as https://{region}.aisearch.tencentelasticsearch.com/v1/." + ) + .setLabel("Region") + .setRequired(false) + .setSensitive(false) + .setUpdatable(false) + .setType(SettingsConfigurationFieldType.STRING) + .build() + ); + + configurationMap.putAll( + DefaultSecretSettings.toSettingsConfigurationWithDescription( + "The TencentCloud AI Gateway API key. Contact the administrator to obtain a token in the format sk-.", + SUPPORTED_TASK_TYPES + ) + ); + configurationMap.putAll(RateLimitSettings.toSettingsConfiguration(SUPPORTED_TASK_TYPES)); + + return new InferenceServiceConfiguration.Builder().setService(NAME) + .setName(SERVICE_NAME) + .setTaskTypes(SUPPORTED_TASK_TYPES) + .setConfigurations(configurationMap) + .build(); + } + ); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionCreator.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionCreator.java new file mode 100644 index 0000000000000..c33e238ae0b62 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionCreator.java @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.action; + +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.external.action.SenderExecutableAction; +import org.elasticsearch.xpack.inference.external.action.SingleInputSenderExecutableAction; +import org.elasticsearch.xpack.inference.external.http.retry.ResponseHandler; +import org.elasticsearch.xpack.inference.external.http.sender.ChatCompletionInput; +import org.elasticsearch.xpack.inference.external.http.sender.EmbeddingsInput; +import org.elasticsearch.xpack.inference.external.http.sender.GenericRequestManager; +import org.elasticsearch.xpack.inference.external.http.sender.QueryAndDocsInputs; +import org.elasticsearch.xpack.inference.external.http.sender.Sender; +import org.elasticsearch.xpack.inference.external.http.sender.UnifiedChatInput; +import org.elasticsearch.xpack.inference.services.ServiceComponents; +import org.elasticsearch.xpack.inference.services.openai.OpenAiChatCompletionResponseHandler; +import org.elasticsearch.xpack.inference.services.openai.response.OpenAiChatCompletionResponseEntity; +import org.elasticsearch.xpack.inference.services.openai.response.OpenAiEmbeddingsResponseEntity; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudResponseHandler; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudChatCompletionRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudEmbeddingsRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudRerankRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.response.TencentCloudRerankResponseEntity; + +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.external.action.ActionUtils.constructFailedToSendRequestMessage; + +/** + * Creates {@link ExecutableAction}s for TencentCloud embeddings, rerank, and completion models using the visitor pattern. + * Unified chat completion (the {@code chat_completion} task type) is handled directly by the + * {@code TencentCloudService#doUnifiedCompletionInfer} path. + */ +public class TencentCloudActionCreator implements TencentCloudActionVisitor { + + private static final String COMPLETION_ERROR_PREFIX = "TencentCloud completions"; + private static final String USER_ROLE = "user"; + + private static final ResponseHandler EMBEDDINGS_HANDLER = new TencentCloudResponseHandler( + "tencentcloud text embedding", + OpenAiEmbeddingsResponseEntity::fromResponse + ); + + private static final ResponseHandler RERANK_HANDLER = new TencentCloudResponseHandler( + "tencentcloud rerank", + (request, response) -> TencentCloudRerankResponseEntity.fromResponse(response) + ); + + private static final ResponseHandler COMPLETION_HANDLER = new OpenAiChatCompletionResponseHandler( + "tencentcloud completion", + OpenAiChatCompletionResponseEntity::fromResponse + ); + + private final Sender sender; + private final ServiceComponents serviceComponents; + + public TencentCloudActionCreator(Sender sender, ServiceComponents serviceComponents) { + this.sender = Objects.requireNonNull(sender); + this.serviceComponents = Objects.requireNonNull(serviceComponents); + } + + @Override + public ExecutableAction create(TencentCloudEmbeddingsModel model, Map taskSettings) { + var requestManager = new GenericRequestManager<>( + serviceComponents.threadPool(), + model, + EMBEDDINGS_HANDLER, + (embeddingsInput) -> new TencentCloudEmbeddingsRequest(embeddingsInput.getTextInputs(), model), + EmbeddingsInput.class + ); + return new SenderExecutableAction(sender, requestManager, constructFailedToSendRequestMessage("TencentCloud embeddings")); + } + + @Override + public ExecutableAction create(TencentCloudRerankModel model, Map taskSettings) { + var overriddenModel = TencentCloudRerankModel.of(model, taskSettings); + var requestManager = new GenericRequestManager<>( + serviceComponents.threadPool(), + overriddenModel, + RERANK_HANDLER, + (rerankInput) -> new TencentCloudRerankRequest( + rerankInput.getQueryAsString(), + rerankInput.getDocsAsStrings(), + rerankInput.getReturnDocuments(), + rerankInput.getTopN(), + overriddenModel + ), + QueryAndDocsInputs.class + ); + return new SenderExecutableAction(sender, requestManager, constructFailedToSendRequestMessage("TencentCloud rerank")); + } + + @Override + public ExecutableAction create(TencentCloudChatCompletionModel model, Map taskSettings) { + var requestManager = new GenericRequestManager<>( + serviceComponents.threadPool(), + model, + COMPLETION_HANDLER, + (chatCompletionInput) -> new TencentCloudChatCompletionRequest(new UnifiedChatInput(chatCompletionInput, USER_ROLE), model), + ChatCompletionInput.class + ); + var errorMessage = constructFailedToSendRequestMessage(COMPLETION_ERROR_PREFIX); + return new SingleInputSenderExecutableAction(sender, requestManager, errorMessage, COMPLETION_ERROR_PREFIX); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionVisitor.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionVisitor.java new file mode 100644 index 0000000000000..455737e27bcc0 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionVisitor.java @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.action; + +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; + +import java.util.Map; + +public interface TencentCloudActionVisitor { + + ExecutableAction create(TencentCloudEmbeddingsModel model, Map taskSettings); + + ExecutableAction create(TencentCloudRerankModel model, Map taskSettings); + + ExecutableAction create(TencentCloudChatCompletionModel model, Map taskSettings); +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModel.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModel.java new file mode 100644 index 0000000000000..84d309b1ca16c --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModel.java @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.completion; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.EmptyTaskSettings; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; +import org.elasticsearch.xpack.inference.services.tencentcloud.action.TencentCloudActionVisitor; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; + +import java.net.URI; +import java.util.Map; + +public class TencentCloudChatCompletionModel extends TencentCloudModel { + + public TencentCloudChatCompletionModel( + String inferenceId, + TaskType taskType, + Map serviceSettings, + @Nullable Map secrets, + ConfigurationParseContext context + ) { + this( + inferenceId, + taskType, + TencentCloudChatCompletionServiceSettings.fromMap(serviceSettings, context), + DefaultSecretSettings.fromMap(secrets, context) + ); + } + + public TencentCloudChatCompletionModel( + String inferenceId, + TaskType taskType, + TencentCloudChatCompletionServiceSettings serviceSettings, + @Nullable DefaultSecretSettings secretSettings + ) { + super( + new ModelConfigurations(inferenceId, taskType, TencentCloudService.NAME, serviceSettings, EmptyTaskSettings.INSTANCE), + new ModelSecrets(secretSettings), + secretSettings, + serviceSettings.getCommonSettings(), + resolveUri(serviceSettings) + ); + } + + public TencentCloudChatCompletionModel(ModelConfigurations config, ModelSecrets secrets) { + super( + config, + secrets, + (DefaultSecretSettings) secrets.getSecretSettings(), + ((TencentCloudChatCompletionServiceSettings) config.getServiceSettings()).getCommonSettings(), + resolveUri((TencentCloudChatCompletionServiceSettings) config.getServiceSettings()) + ); + } + + public String model() { + return getServiceSettings().modelId(); + } + + @Override + public TencentCloudChatCompletionServiceSettings getServiceSettings() { + return (TencentCloudChatCompletionServiceSettings) super.getServiceSettings(); + } + + @Override + public DefaultSecretSettings getSecretSettings() { + return (DefaultSecretSettings) super.getSecretSettings(); + } + + @Override + public ExecutableAction accept(TencentCloudActionVisitor visitor, Map taskSettings) { + return visitor.create(this, taskSettings); + } + + private static URI resolveUri(TencentCloudChatCompletionServiceSettings serviceSettings) { + return TencentCloudUtils.buildUri( + serviceSettings.getCommonSettings().region(), + TencentCloudUtils.VERSION_1, + TencentCloudUtils.CHAT_COMPLETIONS_PATH_1, + TencentCloudUtils.CHAT_COMPLETIONS_PATH_2 + ); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelCreator.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelCreator.java new file mode 100644 index 0000000000000..f9a190956b99d --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelCreator.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.completion; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ChunkingSettings; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ModelCreator; + +import java.util.Map; + +public class TencentCloudChatCompletionModelCreator implements ModelCreator { + + @Override + public TencentCloudChatCompletionModel createFromMaps( + String inferenceId, + TaskType taskType, + String service, + Map serviceSettings, + @Nullable Map taskSettings, + @Nullable ChunkingSettings chunkingSettings, + @Nullable Map secretSettings, + ConfigurationParseContext context + ) { + return new TencentCloudChatCompletionModel(inferenceId, taskType, serviceSettings, secretSettings, context); + } + + @Override + public TencentCloudChatCompletionModel createFromModelConfigurationsAndSecrets(ModelConfigurations config, ModelSecrets secrets) { + return new TencentCloudChatCompletionModel(config, secrets); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java new file mode 100644 index 0000000000000..b5c08e448e8a3 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.completion; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.FilteredXContentObject; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudRateLimitServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +public class TencentCloudChatCompletionServiceSettings extends FilteredXContentObject + implements + ServiceSettings, + TencentCloudRateLimitServiceSettings { + + public static final String NAME = "tencentcloud_chat_completion_service_settings"; + // Chat completion default rate limit is 5 rpm per the AI Gateway docs. + public static final RateLimitSettings DEFAULT_CHAT_COMPLETION_RATE_LIMIT = new RateLimitSettings(5); + + private static final ObjectParser REQUEST_PARSER = createParser(false); + private static final ObjectParser PERSISTENT_PARSER = createParser(true); + + static ObjectParser createParser(boolean ignoreUnknownFields) { + ObjectParser parser = new ObjectParser<>( + ModelConfigurations.SERVICE_SETTINGS, + ignoreUnknownFields, + Builder::new + ); + TencentCloudCommonServiceSettings.declareCommonFields(parser, DEFAULT_CHAT_COMPLETION_RATE_LIMIT); + return parser; + } + + public static TencentCloudChatCompletionServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var parser = context == ConfigurationParseContext.REQUEST ? REQUEST_PARSER : PERSISTENT_PARSER; + var validationException = new ValidationException(); + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, parser, validationException); + validationException.throwIfValidationErrorsExist(); + + return new TencentCloudChatCompletionServiceSettings(commonSettings); + } + + private final TencentCloudCommonServiceSettings commonSettings; + + public TencentCloudChatCompletionServiceSettings(TencentCloudCommonServiceSettings commonSettings) { + this.commonSettings = Objects.requireNonNull(commonSettings); + } + + public TencentCloudChatCompletionServiceSettings(StreamInput in) throws IOException { + this.commonSettings = new TencentCloudCommonServiceSettings(in); + } + + public TencentCloudCommonServiceSettings getCommonSettings() { + return commonSettings; + } + + @Override + public String modelId() { + return commonSettings.modelId(); + } + + @Override + public RateLimitSettings rateLimitSettings() { + return commonSettings.rateLimitSettings(); + } + + @Override + public TencentCloudChatCompletionServiceSettings updateServiceSettings(Map serviceSettings) { + var validationException = new ValidationException(); + var updatedCommonServiceSettings = commonSettings.updateCommonServiceSettings(serviceSettings, validationException); + validationException.throwIfValidationErrorsExist(); + return new TencentCloudChatCompletionServiceSettings(updatedCommonServiceSettings); + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + commonSettings.toXContentFragment(builder, params); + builder.endObject(); + return builder; + } + + @Override + protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder, Params params) throws IOException { + return commonSettings.toXContentFragmentOfExposedFields(builder, params); + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + commonSettings.writeTo(out); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TencentCloudChatCompletionServiceSettings that = (TencentCloudChatCompletionServiceSettings) o; + return Objects.equals(commonSettings, that.commonSettings); + } + + @Override + public int hashCode() { + return Objects.hash(commonSettings); + } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String region; + private RateLimitSettings rateLimitSettings; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setRegion(String region) { + this.region = region; + } + + @Override + public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { + this.rateLimitSettings = rateLimitSettings; + } + + @Override + public TencentCloudCommonServiceSettings buildCommon() { + // When the rate_limit field is absent, the builder's rateLimitSettings stays null and the common constructor + // would apply the general default (20 rpm). Override here so that the chat-completion-specific default (5 rpm) is used. + var rateLimit = rateLimitSettings != null ? rateLimitSettings : DEFAULT_CHAT_COMPLETION_RATE_LIMIT; + return new TencentCloudCommonServiceSettings(modelId, region, rateLimit); + } + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModel.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModel.java new file mode 100644 index 0000000000000..52b587503f24a --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModel.java @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ChunkingSettings; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; +import org.elasticsearch.xpack.inference.services.tencentcloud.action.TencentCloudActionVisitor; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; + +import java.util.Map; + +public class TencentCloudEmbeddingsModel extends TencentCloudModel { + + public TencentCloudEmbeddingsModel( + String inferenceId, + Map serviceSettings, + Map taskSettings, + ChunkingSettings chunkingSettings, + @Nullable Map secrets, + ConfigurationParseContext context + ) { + this( + inferenceId, + TencentCloudEmbeddingsServiceSettings.fromMap(serviceSettings, context), + TencentCloudEmbeddingsTaskSettings.fromMap(taskSettings), + chunkingSettings, + DefaultSecretSettings.fromMap(secrets, context) + ); + } + + public TencentCloudEmbeddingsModel( + String inferenceId, + TencentCloudEmbeddingsServiceSettings serviceSettings, + TencentCloudEmbeddingsTaskSettings taskSettings, + ChunkingSettings chunkingSettings, + @Nullable DefaultSecretSettings secretSettings + ) { + super( + new ModelConfigurations( + inferenceId, + TaskType.TEXT_EMBEDDING, + TencentCloudService.NAME, + serviceSettings, + taskSettings, + chunkingSettings + ), + new ModelSecrets(secretSettings), + secretSettings, + serviceSettings.getCommonSettings(), + resolveUri(serviceSettings) + ); + } + + public TencentCloudEmbeddingsModel(ModelConfigurations config, ModelSecrets secrets) { + super( + config, + secrets, + (DefaultSecretSettings) secrets.getSecretSettings(), + ((TencentCloudEmbeddingsServiceSettings) config.getServiceSettings()).getCommonSettings(), + resolveUri((TencentCloudEmbeddingsServiceSettings) config.getServiceSettings()) + ); + } + + public TencentCloudEmbeddingsModel(TencentCloudEmbeddingsModel model, TencentCloudEmbeddingsServiceSettings serviceSettings) { + super(model, serviceSettings); + } + + @Override + public TencentCloudEmbeddingsServiceSettings getServiceSettings() { + return (TencentCloudEmbeddingsServiceSettings) super.getServiceSettings(); + } + + @Override + public TencentCloudEmbeddingsTaskSettings getTaskSettings() { + return (TencentCloudEmbeddingsTaskSettings) super.getTaskSettings(); + } + + @Override + public DefaultSecretSettings getSecretSettings() { + return (DefaultSecretSettings) super.getSecretSettings(); + } + + @Override + public ExecutableAction accept(TencentCloudActionVisitor visitor, Map taskSettings) { + return visitor.create(this, taskSettings); + } + + private static java.net.URI resolveUri(TencentCloudEmbeddingsServiceSettings serviceSettings) { + return TencentCloudUtils.buildUri( + serviceSettings.getCommonSettings().region(), + TencentCloudUtils.VERSION_1, + TencentCloudUtils.EMBEDDINGS_PATH + ); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelCreator.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelCreator.java new file mode 100644 index 0000000000000..75f61c8941c48 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelCreator.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ChunkingSettings; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ModelCreator; + +import java.util.Map; + +public class TencentCloudEmbeddingsModelCreator implements ModelCreator { + + @Override + public TencentCloudEmbeddingsModel createFromMaps( + String inferenceId, + TaskType taskType, + String service, + Map serviceSettings, + @Nullable Map taskSettings, + @Nullable ChunkingSettings chunkingSettings, + @Nullable Map secretSettings, + ConfigurationParseContext context + ) { + return new TencentCloudEmbeddingsModel(inferenceId, serviceSettings, taskSettings, chunkingSettings, secretSettings, context); + } + + @Override + public TencentCloudEmbeddingsModel createFromModelConfigurationsAndSecrets(ModelConfigurations config, ModelSecrets secrets) { + return new TencentCloudEmbeddingsModel(config, secrets); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java new file mode 100644 index 0000000000000..f078b5a470c1d --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.inference.SimilarityMeasure; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.FilteredXContentObject; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudRateLimitServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.services.ServiceFields.DIMENSIONS; +import static org.elasticsearch.xpack.inference.services.ServiceFields.MAX_INPUT_TOKENS; +import static org.elasticsearch.xpack.inference.services.ServiceFields.SIMILARITY; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractOptionalPositiveInteger; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractSimilarity; + +public class TencentCloudEmbeddingsServiceSettings extends FilteredXContentObject + implements + ServiceSettings, + TencentCloudRateLimitServiceSettings { + + public static final String NAME = "tencentcloud_embeddings_service_settings"; + + private static final ObjectParser REQUEST_PARSER = createParser(false); + private static final ObjectParser PERSISTENT_PARSER = createParser(true); + + static ObjectParser createParser(boolean ignoreUnknownFields) { + ObjectParser parser = new ObjectParser<>( + ModelConfigurations.SERVICE_SETTINGS, + ignoreUnknownFields, + Builder::new + ); + TencentCloudCommonServiceSettings.declareCommonFields(parser, TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS); + parser.declareString(Builder::setSimilarity, SimilarityMeasure::fromString, new ParseField(SIMILARITY)); + parser.declareInt(Builder::setDimensions, new ParseField(DIMENSIONS)); + parser.declareInt(Builder::setMaxInputTokens, new ParseField(MAX_INPUT_TOKENS)); + return parser; + } + + public static TencentCloudEmbeddingsServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var parser = context == ConfigurationParseContext.REQUEST ? REQUEST_PARSER : PERSISTENT_PARSER; + var validationException = new ValidationException(); + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, parser, validationException); + // Fallback to hand-parsing for embeddings-specific fields that were not captured by the common parser + var similarity = extractSimilarity(map, ModelConfigurations.SERVICE_SETTINGS, validationException); + var dimensions = extractOptionalPositiveInteger(map, DIMENSIONS, ModelConfigurations.SERVICE_SETTINGS, validationException); + var maxInputTokens = extractOptionalPositiveInteger( + map, + MAX_INPUT_TOKENS, + ModelConfigurations.SERVICE_SETTINGS, + validationException + ); + + validationException.throwIfValidationErrorsExist(); + + return new TencentCloudEmbeddingsServiceSettings(commonSettings, similarity, dimensions, maxInputTokens); + } + + private final TencentCloudCommonServiceSettings commonSettings; + @Nullable + private final SimilarityMeasure similarity; + @Nullable + private final Integer dimensions; + @Nullable + private final Integer maxInputTokens; + + public TencentCloudEmbeddingsServiceSettings( + TencentCloudCommonServiceSettings commonSettings, + @Nullable SimilarityMeasure similarity, + @Nullable Integer dimensions, + @Nullable Integer maxInputTokens + ) { + this.commonSettings = Objects.requireNonNull(commonSettings); + this.similarity = similarity; + this.dimensions = dimensions; + this.maxInputTokens = maxInputTokens; + } + + public TencentCloudEmbeddingsServiceSettings(StreamInput in) throws IOException { + this.commonSettings = new TencentCloudCommonServiceSettings(in); + this.similarity = in.readOptionalEnum(SimilarityMeasure.class); + this.dimensions = in.readOptionalVInt(); + this.maxInputTokens = in.readOptionalVInt(); + } + + public TencentCloudCommonServiceSettings getCommonSettings() { + return commonSettings; + } + + @Override + public String modelId() { + return commonSettings.modelId(); + } + + @Override + public SimilarityMeasure similarity() { + return similarity; + } + + @Override + public Integer dimensions() { + return dimensions; + } + + @Nullable + public Integer maxInputTokens() { + return maxInputTokens; + } + + @Override + public RateLimitSettings rateLimitSettings() { + return commonSettings.rateLimitSettings(); + } + + @Override + public TencentCloudEmbeddingsServiceSettings updateServiceSettings(Map serviceSettings) { + var validationException = new ValidationException(); + + var extractedMaxInputTokens = extractOptionalPositiveInteger( + serviceSettings, + MAX_INPUT_TOKENS, + ModelConfigurations.SERVICE_SETTINGS, + validationException + ); + + var updatedCommonServiceSettings = commonSettings.updateCommonServiceSettings(serviceSettings, validationException); + + validationException.throwIfValidationErrorsExist(); + + return new TencentCloudEmbeddingsServiceSettings( + updatedCommonServiceSettings, + this.similarity, + this.dimensions, + extractedMaxInputTokens != null ? extractedMaxInputTokens : this.maxInputTokens + ); + } + + /** + * Return an updated copy with the resolved embedding size and similarity measure. + */ + public TencentCloudEmbeddingsServiceSettings updateEmbeddingDetails(int newDimensions, SimilarityMeasure newSimilarity) { + return new TencentCloudEmbeddingsServiceSettings(commonSettings, newSimilarity, newDimensions, maxInputTokens); + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + commonSettings.toXContentFragment(builder, params); + toXContentFragmentOfExposedFields(builder, params); + builder.endObject(); + return builder; + } + + @Override + protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder, Params params) throws IOException { + if (similarity != null) { + builder.field(SIMILARITY, similarity); + } + if (dimensions != null) { + builder.field(DIMENSIONS, dimensions); + } + if (maxInputTokens != null) { + builder.field(MAX_INPUT_TOKENS, maxInputTokens); + } + return builder; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + commonSettings.writeTo(out); + out.writeOptionalEnum(similarity); + out.writeOptionalVInt(dimensions); + out.writeOptionalVInt(maxInputTokens); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TencentCloudEmbeddingsServiceSettings that = (TencentCloudEmbeddingsServiceSettings) o; + return Objects.equals(commonSettings, that.commonSettings) + && similarity == that.similarity + && Objects.equals(dimensions, that.dimensions) + && Objects.equals(maxInputTokens, that.maxInputTokens); + } + + @Override + public int hashCode() { + return Objects.hash(commonSettings, similarity, dimensions, maxInputTokens); + } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String region; + private RateLimitSettings rateLimitSettings; + private SimilarityMeasure similarity; + private Integer dimensions; + private Integer maxInputTokens; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setRegion(String region) { + this.region = region; + } + + @Override + public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { + this.rateLimitSettings = rateLimitSettings; + } + + public void setSimilarity(SimilarityMeasure similarity) { + this.similarity = similarity; + } + + public void setDimensions(Integer dimensions) { + this.dimensions = dimensions; + } + + public void setMaxInputTokens(Integer maxInputTokens) { + this.maxInputTokens = maxInputTokens; + } + + @Override + public TencentCloudCommonServiceSettings buildCommon() { + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); + } + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettings.java new file mode 100644 index 0000000000000..eb22bb6c411b8 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettings.java @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.inference.TaskSettings; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; + +import java.io.IOException; +import java.util.Map; + +/** + * TencentCloud embeddings do not support any task-level settings today. This class is kept as an empty implementation to + * remain compatible with the {@link org.elasticsearch.inference.ModelConfigurations} contract. + */ +public class TencentCloudEmbeddingsTaskSettings implements TaskSettings { + + public static final String NAME = "tencentcloud_embeddings_task_settings"; + public static final TencentCloudEmbeddingsTaskSettings EMPTY_SETTINGS = new TencentCloudEmbeddingsTaskSettings(); + + public static TencentCloudEmbeddingsTaskSettings fromMap(Map map) { + return EMPTY_SETTINGS; + } + + public TencentCloudEmbeddingsTaskSettings() {} + + public TencentCloudEmbeddingsTaskSettings(StreamInput in) throws IOException { + // no fields to read + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.endObject(); + return builder; + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + // no fields to write + } + + @Override + public TaskSettings updatedTaskSettings(Map newSettings) { + return this; + } + + @Override + public boolean equals(Object o) { + return this == o || (o != null && getClass() == o.getClass()); + } + + @Override + public int hashCode() { + return NAME.hashCode(); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudChatCompletionRequest.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudChatCompletionRequest.java new file mode 100644 index 0000000000000..cd6ed70875e73 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudChatCompletionRequest.java @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.inference.UnifiedCompletionRequest; +import org.elasticsearch.xcontent.ToXContent; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xcontent.json.JsonXContent; +import org.elasticsearch.xpack.inference.external.http.sender.UnifiedChatInput; +import org.elasticsearch.xpack.inference.external.request.HttpRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundUnifiedCompletionRequest; +import org.elasticsearch.xpack.inference.external.unified.UnifiedChatCompletionRequestEntity; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.external.request.RequestUtils.createAuthBearerHeader; + +/** + * Outbound request for TencentCloud AI Gateway {@code POST /v1/chat/completions}. + * The Gateway is fully OpenAI-compatible so {@link UnifiedChatCompletionRequestEntity} is reused for the request body. + */ +public class TencentCloudChatCompletionRequest implements OutboundUnifiedCompletionRequest { + + private final TencentCloudChatCompletionModel model; + private final UnifiedChatInput unifiedChatInput; + + public TencentCloudChatCompletionRequest(UnifiedChatInput unifiedChatInput, TencentCloudChatCompletionModel model) { + this.unifiedChatInput = Objects.requireNonNull(unifiedChatInput); + this.model = Objects.requireNonNull(model); + } + + @Override + public void createHttpRequest(ActionListener listener) { + HttpPost httpPost = new HttpPost(model.uri()); + httpPost.setEntity(createEntity()); + + httpPost.setHeader(HttpHeaders.CONTENT_TYPE, XContentType.JSON.mediaType()); + httpPost.setHeader(createAuthBearerHeader(model.apiKey())); + + listener.onResponse(new HttpRequest(httpPost, getInferenceEntityId())); + } + + private ByteArrayEntity createEntity() { + var modelId = Objects.requireNonNullElseGet(unifiedChatInput.getRequest().model(), model::model); + try (var builder = JsonXContent.contentBuilder()) { + builder.startObject(); + new UnifiedChatCompletionRequestEntity(unifiedChatInput).toXContent( + builder, + UnifiedCompletionRequest.withMaxTokens(modelId, ToXContent.EMPTY_PARAMS) + ); + builder.endObject(); + return new ByteArrayEntity(Strings.toString(builder).getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new ElasticsearchException("Failed to serialize TencentCloud chat completion request payload.", e); + } + } + + @Override + public URI getURI() { + return model.uri(); + } + + @Override + public OutboundRequest truncate() { + return this; + } + + @Override + public boolean[] getTruncationInfo() { + return null; + } + + @Override + public String getInferenceEntityId() { + return model.getInferenceEntityId(); + } + + @Override + public TaskType getTaskType() { + return model.getTaskType(); + } + + @Override + public boolean isStreaming() { + return unifiedChatInput.stream(); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequest.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequest.java new file mode 100644 index 0000000000000..200f23cbb0a85 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequest.java @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.external.request.HttpRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundDenseEmbeddingRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudRequestUtils.decorateWithAuthHeader; + +public class TencentCloudEmbeddingsRequest implements OutboundDenseEmbeddingRequest { + + private final List input; + private final TencentCloudEmbeddingsModel model; + + public TencentCloudEmbeddingsRequest(List input, TencentCloudEmbeddingsModel model) { + this.input = Objects.requireNonNull(input); + this.model = Objects.requireNonNull(model); + } + + @Override + public void createHttpRequest(ActionListener listener) { + HttpPost httpPost = new HttpPost(getURI()); + + ByteArrayEntity byteEntity = new ByteArrayEntity( + Strings.toString(new TencentCloudEmbeddingsRequestEntity(input, model)).getBytes(StandardCharsets.UTF_8) + ); + httpPost.setEntity(byteEntity); + + decorateWithAuthHeader(httpPost, model.apiKey()); + + listener.onResponse(new HttpRequest(httpPost, getInferenceEntityId())); + } + + @Override + public String getInferenceEntityId() { + return model.getInferenceEntityId(); + } + + @Override + public URI getURI() { + return model.uri(); + } + + @Override + public OutboundRequest truncate() { + return this; + } + + @Override + public boolean[] getTruncationInfo() { + return null; + } + + @Override + public TaskType getTaskType() { + return model.getTaskType(); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntity.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntity.java new file mode 100644 index 0000000000000..cedfe33e81524 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntity.java @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.elasticsearch.xcontent.ToXContentObject; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** + * Request body for TencentCloud AI Gateway {@code POST /v1/embeddings}. + *

+ *   { "model": "bge-m3", "input": ["..."] }
+ * 
+ */ +public record TencentCloudEmbeddingsRequestEntity(List input, TencentCloudEmbeddingsModel model) implements ToXContentObject { + + public static final String INPUT_FIELD = "input"; + public static final String MODEL_FIELD = "model"; + + public TencentCloudEmbeddingsRequestEntity { + Objects.requireNonNull(input); + Objects.requireNonNull(model); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(MODEL_FIELD, model.getServiceSettings().modelId()); + builder.field(INPUT_FIELD, input); + builder.endObject(); + return builder; + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRequestUtils.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRequestUtils.java new file mode 100644 index 0000000000000..4943226b87ea9 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRequestUtils.java @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.HttpPost; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.xcontent.XContentType; + +import static org.elasticsearch.xpack.inference.external.request.RequestUtils.createAuthBearerHeader; + +public final class TencentCloudRequestUtils { + + public static void decorateWithAuthHeader(HttpPost request, SecureString apiKey) { + request.setHeader(HttpHeaders.CONTENT_TYPE, XContentType.JSON.mediaType()); + request.setHeader(createAuthBearerHeader(apiKey)); + } + + private TencentCloudRequestUtils() {} +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequest.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequest.java new file mode 100644 index 0000000000000..724fc241c3f08 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequest.java @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.Strings; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.xpack.inference.external.request.HttpRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundRequest; +import org.elasticsearch.xpack.inference.external.request.OutboundRerankRequest; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudRequestUtils.decorateWithAuthHeader; + +public class TencentCloudRerankRequest implements OutboundRerankRequest { + + private final String query; + private final List input; + private final Boolean returnDocuments; + private final Integer topN; + private final TencentCloudRerankModel model; + + public TencentCloudRerankRequest( + String query, + List input, + @Nullable Boolean returnDocuments, + @Nullable Integer topN, + TencentCloudRerankModel model + ) { + this.query = Objects.requireNonNull(query); + this.input = Objects.requireNonNull(input); + this.returnDocuments = returnDocuments; + this.topN = topN; + this.model = Objects.requireNonNull(model); + } + + @Override + public void createHttpRequest(ActionListener listener) { + HttpPost httpPost = new HttpPost(getURI()); + + ByteArrayEntity byteEntity = new ByteArrayEntity( + Strings.toString(new TencentCloudRerankRequestEntity(query, input, returnDocuments, topN, model)) + .getBytes(StandardCharsets.UTF_8) + ); + httpPost.setEntity(byteEntity); + + decorateWithAuthHeader(httpPost, model.apiKey()); + + listener.onResponse(new HttpRequest(httpPost, getInferenceEntityId())); + } + + @Override + public String getInferenceEntityId() { + return model.getInferenceEntityId(); + } + + @Override + public URI getURI() { + return model.uri(); + } + + @Override + public OutboundRequest truncate() { + return this; + } + + @Override + public boolean[] getTruncationInfo() { + return null; + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntity.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntity.java new file mode 100644 index 0000000000000..e6fd1ad1b4519 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntity.java @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.xcontent.ToXContentObject; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankTaskSettings; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** + * Request body for TencentCloud AI Gateway {@code POST /v1/rerank}. + *
+ *   { "model": "bge-reranker-v2-m3", "query": "...", "documents": [...], "top_n": 3, "return_documents": true }
+ * 
+ */ +public record TencentCloudRerankRequestEntity( + String query, + List documents, + @Nullable Boolean returnDocuments, + @Nullable Integer topN, + TencentCloudRerankModel model +) implements ToXContentObject { + + public static final String QUERY_FIELD = "query"; + public static final String DOCUMENTS_FIELD = "documents"; + public static final String MODEL_FIELD = "model"; + + public TencentCloudRerankRequestEntity { + Objects.requireNonNull(query); + Objects.requireNonNull(documents); + Objects.requireNonNull(model); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(MODEL_FIELD, model.getServiceSettings().modelId()); + builder.field(QUERY_FIELD, query); + builder.field(DOCUMENTS_FIELD, documents); + + TencentCloudRerankTaskSettings taskSettings = model.getTaskSettings(); + + // Prefer the request-level top_n over task settings, then default from task settings. + if (topN != null) { + builder.field(TencentCloudRerankTaskSettings.TOP_N, topN); + } else if (taskSettings.getTopN() != null) { + builder.field(TencentCloudRerankTaskSettings.TOP_N, taskSettings.getTopN()); + } + + if (returnDocuments != null) { + builder.field(TencentCloudRerankTaskSettings.RETURN_DOCUMENTS, returnDocuments); + } else if (taskSettings.getReturnDocuments() != null) { + builder.field(TencentCloudRerankTaskSettings.RETURN_DOCUMENTS, taskSettings.getReturnDocuments()); + } + + builder.endObject(); + return builder; + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudUtils.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudUtils.java new file mode 100644 index 0000000000000..025865bead0ee --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.apache.http.client.utils.URIBuilder; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; + +public final class TencentCloudUtils { + + public static final String SCHEME = "https"; + public static final String DEFAULT_REGION = "bj"; + private static final String HOST_SUFFIX = ".aisearch.tencentelasticsearch.com"; + public static final String VERSION_1 = "v1"; + public static final String EMBEDDINGS_PATH = "embeddings"; + public static final String CHAT_COMPLETIONS_PATH_1 = "chat"; + public static final String CHAT_COMPLETIONS_PATH_2 = "completions"; + public static final String RERANK_PATH = "rerank"; + + private TencentCloudUtils() {} + + public static String buildHost(String region) { + return region + HOST_SUFFIX; + } + + public static URI buildUri(String region, String firstPathSegment, String... morePathSegments) { + List segments = new ArrayList<>(); + segments.add(firstPathSegment); + for (String s : morePathSegments) { + segments.add(s); + } + var builder = new URIBuilder().setScheme(SCHEME).setHost(buildHost(region)).setPathSegments(segments); + try { + return builder.build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Failed to build TencentCloud URI for region [" + region + "]", e); + } + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModel.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModel.java new file mode 100644 index 0000000000000..47a9a31d52b7e --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModel.java @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.external.action.ExecutableAction; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; +import org.elasticsearch.xpack.inference.services.tencentcloud.action.TencentCloudActionVisitor; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; + +import java.net.URI; +import java.util.Map; + +public class TencentCloudRerankModel extends TencentCloudModel { + + public static TencentCloudRerankModel of(TencentCloudRerankModel model, Map taskSettings) { + var requestTaskSettings = TencentCloudRerankTaskSettings.fromMap(taskSettings); + if (requestTaskSettings.isEmpty() || requestTaskSettings.equals(model.getTaskSettings())) { + return model; + } + return new TencentCloudRerankModel(model, TencentCloudRerankTaskSettings.of(model.getTaskSettings(), requestTaskSettings)); + } + + public TencentCloudRerankModel( + String inferenceId, + Map serviceSettings, + Map taskSettings, + @Nullable Map secrets, + ConfigurationParseContext context + ) { + this( + inferenceId, + TencentCloudRerankServiceSettings.fromMap(serviceSettings, context), + TencentCloudRerankTaskSettings.fromMap(taskSettings), + DefaultSecretSettings.fromMap(secrets, context) + ); + } + + public TencentCloudRerankModel( + String inferenceId, + TencentCloudRerankServiceSettings serviceSettings, + TencentCloudRerankTaskSettings taskSettings, + @Nullable DefaultSecretSettings secretSettings + ) { + super( + new ModelConfigurations(inferenceId, TaskType.RERANK, TencentCloudService.NAME, serviceSettings, taskSettings), + new ModelSecrets(secretSettings), + secretSettings, + serviceSettings.getCommonSettings(), + resolveUri(serviceSettings) + ); + } + + public TencentCloudRerankModel(ModelConfigurations config, ModelSecrets secrets) { + super( + config, + secrets, + (DefaultSecretSettings) secrets.getSecretSettings(), + ((TencentCloudRerankServiceSettings) config.getServiceSettings()).getCommonSettings(), + resolveUri((TencentCloudRerankServiceSettings) config.getServiceSettings()) + ); + } + + private TencentCloudRerankModel(TencentCloudRerankModel model, TencentCloudRerankTaskSettings taskSettings) { + super(model, taskSettings); + } + + public TencentCloudRerankModel(TencentCloudRerankModel model, TencentCloudRerankServiceSettings serviceSettings) { + super(model, serviceSettings); + } + + @Override + public TencentCloudRerankServiceSettings getServiceSettings() { + return (TencentCloudRerankServiceSettings) super.getServiceSettings(); + } + + @Override + public TencentCloudRerankTaskSettings getTaskSettings() { + return (TencentCloudRerankTaskSettings) super.getTaskSettings(); + } + + @Override + public DefaultSecretSettings getSecretSettings() { + return (DefaultSecretSettings) super.getSecretSettings(); + } + + @Override + public ExecutableAction accept(TencentCloudActionVisitor visitor, Map taskSettings) { + return visitor.create(this, taskSettings); + } + + private static URI resolveUri(TencentCloudRerankServiceSettings serviceSettings) { + return TencentCloudUtils.buildUri( + serviceSettings.getCommonSettings().region(), + TencentCloudUtils.VERSION_1, + TencentCloudUtils.RERANK_PATH + ); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelCreator.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelCreator.java new file mode 100644 index 0000000000000..64cd04c2f8e1d --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelCreator.java @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ChunkingSettings; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ModelCreator; + +import java.util.Map; + +public class TencentCloudRerankModelCreator implements ModelCreator { + + @Override + public TencentCloudRerankModel createFromMaps( + String inferenceId, + TaskType taskType, + String service, + Map serviceSettings, + @Nullable Map taskSettings, + @Nullable ChunkingSettings chunkingSettings, + @Nullable Map secretSettings, + ConfigurationParseContext context + ) { + return new TencentCloudRerankModel(inferenceId, serviceSettings, taskSettings, secretSettings, context); + } + + @Override + public TencentCloudRerankModel createFromModelConfigurationsAndSecrets(ModelConfigurations config, ModelSecrets secrets) { + return new TencentCloudRerankModel(config, secrets); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java new file mode 100644 index 0000000000000..0fbe04c1ba8fb --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.settings.FilteredXContentObject; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudRateLimitServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +public class TencentCloudRerankServiceSettings extends FilteredXContentObject + implements + ServiceSettings, + TencentCloudRateLimitServiceSettings { + + public static final String NAME = "tencentcloud_rerank_service_settings"; + + private static final ObjectParser REQUEST_PARSER = createParser(false); + private static final ObjectParser PERSISTENT_PARSER = createParser(true); + + static ObjectParser createParser(boolean ignoreUnknownFields) { + ObjectParser parser = new ObjectParser<>( + ModelConfigurations.SERVICE_SETTINGS, + ignoreUnknownFields, + Builder::new + ); + TencentCloudCommonServiceSettings.declareCommonFields(parser, TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS); + return parser; + } + + public static TencentCloudRerankServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var parser = context == ConfigurationParseContext.REQUEST ? REQUEST_PARSER : PERSISTENT_PARSER; + var validationException = new ValidationException(); + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, parser, validationException); + validationException.throwIfValidationErrorsExist(); + return new TencentCloudRerankServiceSettings(commonSettings); + } + + private final TencentCloudCommonServiceSettings commonSettings; + + public TencentCloudRerankServiceSettings(TencentCloudCommonServiceSettings commonSettings) { + this.commonSettings = Objects.requireNonNull(commonSettings); + } + + public TencentCloudRerankServiceSettings(StreamInput in) throws IOException { + this.commonSettings = new TencentCloudCommonServiceSettings(in); + } + + public TencentCloudCommonServiceSettings getCommonSettings() { + return commonSettings; + } + + @Override + public String modelId() { + return commonSettings.modelId(); + } + + @Override + public RateLimitSettings rateLimitSettings() { + return commonSettings.rateLimitSettings(); + } + + @Override + public TencentCloudRerankServiceSettings updateServiceSettings(Map serviceSettings) { + var validationException = new ValidationException(); + var updatedCommonServiceSettings = commonSettings.updateCommonServiceSettings(serviceSettings, validationException); + validationException.throwIfValidationErrorsExist(); + return new TencentCloudRerankServiceSettings(updatedCommonServiceSettings); + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + commonSettings.toXContentFragment(builder, params); + builder.endObject(); + return builder; + } + + @Override + protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder, Params params) throws IOException { + return commonSettings.toXContentFragmentOfExposedFields(builder, params); + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + commonSettings.writeTo(out); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TencentCloudRerankServiceSettings that = (TencentCloudRerankServiceSettings) o; + return Objects.equals(commonSettings, that.commonSettings); + } + + @Override + public int hashCode() { + return Objects.hash(commonSettings); + } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String region; + private RateLimitSettings rateLimitSettings; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setRegion(String region) { + this.region = region; + } + + @Override + public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { + this.rateLimitSettings = rateLimitSettings; + } + + @Override + public TencentCloudCommonServiceSettings buildCommon() { + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); + } + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettings.java new file mode 100644 index 0000000000000..380d2eb45ff08 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettings.java @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.TaskSettings; +import org.elasticsearch.inference.TopNProvider; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractOptionalBoolean; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractOptionalPositiveInteger; + +/** + * Task settings for TencentCloud rerank: {@code top_n} and {@code return_documents}. + */ +public class TencentCloudRerankTaskSettings implements TaskSettings, TopNProvider { + + public static final String NAME = "tencentcloud_rerank_task_settings"; + public static final String RETURN_DOCUMENTS = "return_documents"; + public static final String TOP_N = "top_n"; + + public static final TencentCloudRerankTaskSettings EMPTY_SETTINGS = new TencentCloudRerankTaskSettings(null, null); + + public static TencentCloudRerankTaskSettings fromMap(Map map) { + ValidationException validationException = new ValidationException(); + + if (map == null || map.isEmpty()) { + return EMPTY_SETTINGS; + } + + Boolean returnDocuments = extractOptionalBoolean(map, RETURN_DOCUMENTS, validationException); + Integer topN = extractOptionalPositiveInteger(map, TOP_N, ModelConfigurations.TASK_SETTINGS, validationException); + + validationException.throwIfValidationErrorsExist(); + + if (returnDocuments == null && topN == null) { + return EMPTY_SETTINGS; + } + + return new TencentCloudRerankTaskSettings(topN, returnDocuments); + } + + /** + * Merge task settings, preferring non-null fields from {@code requestTaskSettings}. + */ + public static TencentCloudRerankTaskSettings of( + TencentCloudRerankTaskSettings originalSettings, + TencentCloudRerankTaskSettings requestTaskSettings + ) { + return new TencentCloudRerankTaskSettings( + requestTaskSettings.getTopN() != null ? requestTaskSettings.getTopN() : originalSettings.getTopN(), + requestTaskSettings.getReturnDocuments() != null + ? requestTaskSettings.getReturnDocuments() + : originalSettings.getReturnDocuments() + ); + } + + private final Integer topN; + private final Boolean returnDocuments; + + public TencentCloudRerankTaskSettings(@Nullable Integer topN, @Nullable Boolean returnDocuments) { + this.topN = topN; + this.returnDocuments = returnDocuments; + } + + public TencentCloudRerankTaskSettings(StreamInput in) throws IOException { + this(in.readOptionalInt(), in.readOptionalBoolean()); + } + + @Override + public boolean isEmpty() { + return topN == null && returnDocuments == null; + } + + @Override + public Integer getTopN() { + return topN; + } + + public Boolean getReturnDocuments() { + return returnDocuments; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (topN != null) { + builder.field(TOP_N, topN); + } + if (returnDocuments != null) { + builder.field(RETURN_DOCUMENTS, returnDocuments); + } + builder.endObject(); + return builder; + } + + @Override + public String getWriteableName() { + return NAME; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeOptionalInt(topN); + out.writeOptionalBoolean(returnDocuments); + } + + @Override + public TaskSettings updatedTaskSettings(Map newSettings) { + return of(this, TencentCloudRerankTaskSettings.fromMap(newSettings)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TencentCloudRerankTaskSettings that = (TencentCloudRerankTaskSettings) o; + return Objects.equals(topN, that.topN) && Objects.equals(returnDocuments, that.returnDocuments); + } + + @Override + public int hashCode() { + return Objects.hash(topN, returnDocuments); + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntity.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntity.java new file mode 100644 index 0000000000000..464774ce0b1a3 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntity.java @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.response; + +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.inference.external.http.HttpResult; +import org.elasticsearch.xpack.inference.external.http.retry.ErrorResponse; + +import java.util.Map; + +/** + * Parses TencentCloud AI Gateway error responses. The response body follows the OpenAI-compatible shape: + *
+ *   {
+ *     "error": { "message": "...", "type": "...", "code": "..." }
+ *   }
+ * 
+ */ +public class TencentCloudErrorResponseEntity extends ErrorResponse { + + private TencentCloudErrorResponseEntity(String errorMessage) { + super(errorMessage); + } + + @SuppressWarnings("unchecked") + public static ErrorResponse fromResponse(HttpResult response) { + try ( + XContentParser jsonParser = XContentFactory.xContent(XContentType.JSON) + .createParser(XContentParserConfiguration.EMPTY, response.body()) + ) { + var responseMap = jsonParser.map(); + var error = responseMap.get("error"); + if (error instanceof Map errorMap) { + var message = (String) ((Map) errorMap).get("message"); + if (message != null) { + return new TencentCloudErrorResponseEntity(message); + } + } + var message = (String) responseMap.get("message"); + if (message != null) { + return new TencentCloudErrorResponseEntity(message); + } + } catch (Exception e) { + // swallow the error, return UNDEFINED_ERROR below + } + + return ErrorResponse.UNDEFINED_ERROR; + } +} diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntity.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntity.java new file mode 100644 index 0000000000000..3a77ec74f14da --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntity.java @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.response; + +import org.elasticsearch.core.Nullable; +import org.elasticsearch.inference.InferenceServiceResults; +import org.elasticsearch.xcontent.ConstructingObjectParser; +import org.elasticsearch.xcontent.ObjectParser; +import org.elasticsearch.xcontent.ParseField; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentParseException; +import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.core.inference.results.RankedDocsResults; +import org.elasticsearch.xpack.inference.external.http.HttpResult; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Parses the TencentCloud rerank response, which follows the shape: + *
+ *   {
+ *     "object": "list",
+ *     "results": [
+ *       { "index": 0, "relevance_score": 0.98, "document": "..." }
+ *     ],
+ *     "model": "bge-reranker-v2-m3",
+ *     "usage": { "total_tokens": 45 }
+ *   }
+ * 
+ * The {@code document} field is optional and, when present, may be a plain string or an object containing a {@code text} field. + */ +public class TencentCloudRerankResponseEntity { + + public static InferenceServiceResults fromResponse(HttpResult response) throws IOException { + try (var p = XContentFactory.xContent(XContentType.JSON).createParser(XContentParserConfiguration.EMPTY, response.body())) { + return Response.PARSER.apply(p, null).toRankedDocsResults(); + } + } + + private record Response(List results) { + @SuppressWarnings("unchecked") + public static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + Response.class.getSimpleName(), + true, + args -> new Response((List) args[0]) + ); + + static { + PARSER.declareObjectArray(constructorArg(), ResultItem.PARSER::apply, new ParseField("results")); + } + + public RankedDocsResults toRankedDocsResults() { + List rankedDocs = results.stream() + .map(item -> new RankedDocsResults.RankedDoc(item.index(), item.relevanceScore(), item.document())) + .toList(); + return new RankedDocsResults(rankedDocs); + } + } + + private record ResultItem(int index, float relevanceScore, @Nullable String document) { + public static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + ResultItem.class.getSimpleName(), + true, + args -> new ResultItem((Integer) args[0], (Float) args[1], (String) args[2]) + ); + + static { + PARSER.declareInt(constructorArg(), new ParseField("index")); + PARSER.declareFloat(constructorArg(), new ParseField("relevance_score")); + PARSER.declareField( + optionalConstructorArg(), + (p, c) -> parseDocument(p), + new ParseField("document"), + ObjectParser.ValueType.OBJECT_OR_STRING + ); + } + } + + private static String parseDocument(XContentParser parser) throws IOException { + var token = parser.currentToken(); + if (token == XContentParser.Token.VALUE_STRING) { + return parser.text(); + } else if (token == XContentParser.Token.START_OBJECT) { + return DocumentObject.PARSER.apply(parser, null).text(); + } + throw new XContentParseException(parser.getTokenLocation(), "Expected an object or string for document field, but got: " + token); + } + + private record DocumentObject(String text) { + public static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + DocumentObject.class.getSimpleName(), + true, + args -> new DocumentObject((String) args[0]) + ); + + static { + PARSER.declareString(constructorArg(), new ParseField("text")); + } + } + + private TencentCloudRerankResponseEntity() {} +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettingsTests.java new file mode 100644 index 0000000000000..e7acef48f5638 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettingsTests.java @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudCommonServiceSettingsTests extends AbstractBWCWireSerializationTestCase { + + private static final String TEST_MODEL_ID = "bge-m3"; + private static final String INITIAL_TEST_MODEL_ID = "bge-large-zh-v1.5"; + private static final String TEST_REGION = "gz"; + private static final int TEST_RATE_LIMIT = 100; + private static final int INITIAL_TEST_RATE_LIMIT = 30; + + public static TencentCloudCommonServiceSettings createRandom() { + var modelId = randomAlphaOfLength(10); + var region = randomBoolean() ? randomAlphaOfLength(5) : TencentCloudUtils.DEFAULT_REGION; + var rateLimitSettings = randomBoolean() ? new RateLimitSettings(randomIntBetween(1, 1000)) : null; + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); + } + + public void testConstructor_NullRegion_UsesDefault() { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, null, null); + assertThat(settings.region(), is(TencentCloudUtils.DEFAULT_REGION)); + } + + public void testConstructor_ExplicitRegion() { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, TEST_REGION, null); + assertThat(settings.region(), is(TEST_REGION)); + } + + public void testUpdateServiceSettings_OnlyRateLimitIsMutable() { + var originalSettings = new TencentCloudCommonServiceSettings( + INITIAL_TEST_MODEL_ID, + TEST_REGION, + new RateLimitSettings(INITIAL_TEST_RATE_LIMIT) + ); + var updatedSettings = originalSettings.updateCommonServiceSettings( + new HashMap<>( + Map.of(RateLimitSettings.FIELD_NAME, new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, TEST_RATE_LIMIT))) + ), + new ValidationException() + ); + + // model id and region are immutable + assertThat(updatedSettings.modelId(), is(INITIAL_TEST_MODEL_ID)); + assertThat(updatedSettings.region(), is(TEST_REGION)); + assertThat(updatedSettings.rateLimitSettings(), is(new RateLimitSettings(TEST_RATE_LIMIT))); + } + + public void testUpdateServiceSettings_EmptyMap_DoesNotChangeSettings() { + var originalSettings = new TencentCloudCommonServiceSettings( + INITIAL_TEST_MODEL_ID, + TEST_REGION, + new RateLimitSettings(INITIAL_TEST_RATE_LIMIT) + ); + var updatedSettings = originalSettings.updateCommonServiceSettings(new HashMap<>(), new ValidationException()); + assertThat(updatedSettings, is(originalSettings)); + } + + public void testXContent_WithRegion() throws IOException { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, TEST_REGION, new RateLimitSettings(TEST_RATE_LIMIT)); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + settings.toXContent(builder, null); + String xContentResult = Strings.toString(builder); + + assertThat(xContentResult, is(Strings.format(""" + {"model_id":"%s","region":"%s","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_REGION, TEST_RATE_LIMIT))); + } + + public void testXContent_DefaultRegion() throws IOException { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, null, new RateLimitSettings(TEST_RATE_LIMIT)); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + settings.toXContent(builder, null); + String xContentResult = Strings.toString(builder); + + assertThat(xContentResult, is(Strings.format(""" + {"model_id":"%s","region":"bj","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_RATE_LIMIT))); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudCommonServiceSettings::new; + } + + @Override + protected TencentCloudCommonServiceSettings createTestInstance() { + return createRandom(); + } + + @Override + protected TencentCloudCommonServiceSettings mutateInstance(TencentCloudCommonServiceSettings instance) throws IOException { + var modelId = instance.modelId(); + var region = instance.region(); + var rateLimitSettings = instance.rateLimitSettings(); + + switch (between(0, 2)) { + case 0 -> modelId = randomValueOtherThan(modelId, () -> randomAlphaOfLength(8)); + case 1 -> region = randomValueOtherThan(region, () -> randomAlphaOfLength(5)); + case 2 -> rateLimitSettings = randomValueOtherThan(rateLimitSettings, () -> new RateLimitSettings(randomIntBetween(1, 1000))); + default -> throw new AssertionError("Illegal randomisation branch"); + } + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); + } + + @Override + protected TencentCloudCommonServiceSettings mutateInstanceForVersion(TencentCloudCommonServiceSettings instance, TransportVersion v) { + return instance; + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudServiceTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudServiceTests.java new file mode 100644 index 0000000000000..fbc7cebf7c2f0 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudServiceTests.java @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud; + +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.TestPlainActionFuture; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.TimeValue; +import org.elasticsearch.inference.ChunkInferenceInput; +import org.elasticsearch.inference.ChunkedInference; +import org.elasticsearch.inference.InferenceService; +import org.elasticsearch.inference.InputType; +import org.elasticsearch.inference.Model; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.RerankingInferenceService; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.inference.SimilarityMeasure; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.inference.UnparsedModel; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.inference.external.http.sender.HttpRequestSenderTests; +import org.elasticsearch.xpack.inference.services.InferenceServiceTestCase; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsTaskSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankTaskSettings; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.elasticsearch.action.support.ActionTestUtils.assertNoFailureListener; +import static org.elasticsearch.action.support.ActionTestUtils.assertNoSuccessListener; +import static org.elasticsearch.common.Strings.format; +import static org.elasticsearch.xpack.inference.Utils.mockClusterServiceEmpty; +import static org.elasticsearch.xpack.inference.services.ServiceComponentsTests.createWithEmptySettings; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TencentCloudServiceTests extends InferenceServiceTestCase { + + private static final TimeValue TIMEOUT = new TimeValue(30, TimeUnit.SECONDS); + + public void testName_IsTencentCloud() throws IOException { + try (var service = createService()) { + assertThat(service.name(), is("tencentcloud")); + } + } + + public void testSupportedTaskTypes_ContainsExpectedTasks() throws IOException { + try (var service = createService()) { + assertThat(service.supportedTaskTypes().contains(TaskType.TEXT_EMBEDDING), is(true)); + assertThat(service.supportedTaskTypes().contains(TaskType.COMPLETION), is(true)); + assertThat(service.supportedTaskTypes().contains(TaskType.CHAT_COMPLETION), is(true)); + assertThat(service.supportedTaskTypes().contains(TaskType.RERANK), is(true)); + } + } + + public void testParseRequestConfig_TextEmbedding() throws IOException { + var region = "bj"; + parseRequestConfig(TaskType.TEXT_EMBEDDING, format(""" + { + "service_settings": { + "api_key": "sk-12345", + "model_id": "bge-m3", + "region": "%s" + } + } + """, region), assertNoFailureListener(model -> { + assertThat(model, isA(TencentCloudEmbeddingsModel.class)); + var m = (TencentCloudEmbeddingsModel) model; + assertThat(m.getServiceSettings().modelId(), equalTo("bge-m3")); + assertThat(m.uri().toString(), equalTo("https://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); + assertThat(m.apiKey().toString(), equalTo("sk-12345")); + })); + } + + public void testParseRequestConfig_ChatCompletion_UsesRegionUri() throws IOException { + parseRequestConfig(TaskType.CHAT_COMPLETION, """ + { + "service_settings": { + "api_key": "sk-12345", + "model_id": "deepseek-v3", + "region": "sh" + } + } + """, assertNoFailureListener(model -> { + assertThat(model, isA(TencentCloudChatCompletionModel.class)); + var m = (TencentCloudChatCompletionModel) model; + assertThat(m.model(), equalTo("deepseek-v3")); + assertThat(m.uri().toString(), equalTo("https://sh.aisearch.tencentelasticsearch.com/v1/chat/completions")); + })); + } + + public void testParseRequestConfig_Rerank_WithTaskSettings() throws IOException { + parseRequestConfig(TaskType.RERANK, """ + { + "service_settings": { + "api_key": "sk-12345", + "model_id": "bge-reranker-v2-m3" + }, + "task_settings": { + "top_n": 5, + "return_documents": true + } + } + """, assertNoFailureListener(model -> { + assertThat(model, isA(TencentCloudRerankModel.class)); + var m = (TencentCloudRerankModel) model; + assertThat(m.getServiceSettings().modelId(), equalTo("bge-reranker-v2-m3")); + assertThat(m.getTaskSettings().getTopN(), equalTo(5)); + assertThat(m.getTaskSettings().getReturnDocuments(), equalTo(true)); + })); + } + + public void testParseRequestConfig_MissingApiKey_Fails() throws IOException { + parseRequestConfig(TaskType.TEXT_EMBEDDING, """ + { + "service_settings": { + "model_id": "bge-m3" + } + } + """, assertNoSuccessListener(e -> { + if (e instanceof ValidationException ve) { + assertThat(ve.getMessage().contains("api_key"), is(true)); + } + })); + } + + public void testParseRequestConfig_MissingModelId_Fails() throws IOException { + parseRequestConfig(TaskType.TEXT_EMBEDDING, """ + { + "service_settings": { + "api_key": "sk-12345" + } + } + """, assertNoSuccessListener(e -> { + if (e instanceof ValidationException ve) { + assertThat(ve.getMessage().contains("model_id"), is(true)); + } + })); + } + + public void testParsePersistedConfig_ChatCompletion() throws IOException { + var asMap = map(""" + { + "service_settings": { + "model_id": "deepseek-v3", + "region": "sh" + } + } + """); + Map serviceSettings = new HashMap<>(); + serviceSettings.put(ModelConfigurations.SERVICE_SETTINGS, asMap.get(ModelConfigurations.SERVICE_SETTINGS)); + try (var service = createService()) { + var model = service.parsePersistedConfig( + new UnparsedModel("inference-id", TaskType.CHAT_COMPLETION, TencentCloudService.NAME, serviceSettings, null) + ); + assertThat(model, isA(TencentCloudChatCompletionModel.class)); + var m = (TencentCloudChatCompletionModel) model; + assertThat(m.model(), equalTo("deepseek-v3")); + assertThat(m.uri().toString(), equalTo("https://sh.aisearch.tencentelasticsearch.com/v1/chat/completions")); + } + } + + public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOException { + var modelConfigurations = new ModelConfigurations( + "inference-id", + TaskType.SPARSE_EMBEDDING, + TencentCloudService.NAME, + mock(ServiceSettings.class) + ); + try (var service = createInferenceService()) { + var e = expectThrows( + ElasticsearchStatusException.class, + () -> service.buildModelFromConfigAndSecrets(modelConfigurations, mock(ModelSecrets.class)) + ); + assertThat( + e.getMessage(), + is(format("The [%s] service does not support task type [%s]", "tencentcloud", TaskType.SPARSE_EMBEDDING)) + ); + } + } + + public void testRerankerWindowSize_ReturnsConservativeValue() throws IOException { + try (var service = createService()) { + assertThat(service.rerankerWindowSize("bge-reranker-v2-m3"), is(350)); + } + } + + @Override + public EnumSet expectedStreamingTasks() { + return EnumSet.of(TaskType.COMPLETION, TaskType.CHAT_COMPLETION); + } + + @Override + protected void assertRerankerWindowSize(RerankingInferenceService rerankingInferenceService) { + assertThat(rerankingInferenceService.rerankerWindowSize("bge-reranker-v2-m3"), is(350)); + } + + @Override + public Model createEmbeddingModel(SimilarityMeasure similarity) { + var commonSettings = new TencentCloudCommonServiceSettings( + "bge-m3", + "bj", + new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(20) + ); + var serviceSettings = new TencentCloudEmbeddingsServiceSettings(commonSettings, similarity, null, null); + return new TencentCloudEmbeddingsModel( + "inference-id", + serviceSettings, + TencentCloudEmbeddingsTaskSettings.EMPTY_SETTINGS, + null, + new DefaultSecretSettings(new SecureString("sk-12345")) + ); + } + + private TencentCloudService createService() { + return new TencentCloudService( + HttpRequestSenderTests.createSenderFactory(threadPool, clientManager), + createWithEmptySettings(threadPool), + mockClusterServiceEmpty() + ); + } + + @Override + public InferenceService createInferenceService() { + return createService(); + } + + private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskType) throws URISyntaxException { + var commonSettings = new TencentCloudCommonServiceSettings( + "deepseek-v3", + "bj", + new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(5) + ); + return new TencentCloudChatCompletionModel( + "inference-id", + taskType, + new TencentCloudChatCompletionServiceSettings(commonSettings), + new DefaultSecretSettings(new SecureString("sk-12345")) + ); + } + + private TencentCloudRerankModel createRerankModel(String modelId) throws URISyntaxException { + var commonSettings = new TencentCloudCommonServiceSettings( + modelId, + "bj", + new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(20) + ); + return new TencentCloudRerankModel( + "inference-id", + new TencentCloudRerankServiceSettings(commonSettings), + TencentCloudRerankTaskSettings.EMPTY_SETTINGS, + new DefaultSecretSettings(new SecureString("sk-12345")) + ); + } + + private void parseRequestConfig(TaskType taskType, String json, ActionListener listener) throws IOException { + try (var service = createService()) { + service.parseRequestConfig("inference-id", taskType, map(json), listener); + } + } + + private Map map(String json) throws IOException { + try ( + var parser = XContentType.JSON.xContent().createParser(XContentParserConfiguration.EMPTY, json.getBytes(StandardCharsets.UTF_8)) + ) { + return parser.map(); + } + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelTests.java new file mode 100644 index 0000000000000..2709d11ddff35 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelTests.java @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.completion; + +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudChatCompletionModelTests extends ESTestCase { + + public void testUri_UsesDefaultRegion() { + var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", null, new RateLimitSettings(5))); + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); + } + + public void testUri_UsesCustomRegion() { + var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", "sh", new RateLimitSettings(5))); + assertThat(model.uri().toString(), is("https://sh.aisearch.tencentelasticsearch.com/v1/chat/completions")); + } + + public void testModelIdAccessor() { + var model = createModel(new TencentCloudCommonServiceSettings("deepseek-r1", null, new RateLimitSettings(5))); + assertThat(model.model(), is("deepseek-r1")); + assertThat(model.getTaskType(), is(TaskType.CHAT_COMPLETION)); + } + + private static TencentCloudChatCompletionModel createModel(TencentCloudCommonServiceSettings commonSettings) { + return new TencentCloudChatCompletionModel( + "test-inference-id", + TaskType.CHAT_COMPLETION, + new TencentCloudChatCompletionServiceSettings(commonSettings), + new DefaultSecretSettings(new SecureString("sk-test".toCharArray())) + ); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettingsTests.java new file mode 100644 index 0000000000000..a6036286395e3 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettingsTests.java @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.completion; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettingsTests; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudChatCompletionServiceSettingsTests extends AbstractBWCWireSerializationTestCase< + TencentCloudChatCompletionServiceSettings> { + + public static TencentCloudChatCompletionServiceSettings createRandom() { + return new TencentCloudChatCompletionServiceSettings(TencentCloudCommonServiceSettingsTests.createRandom()); + } + + public void testFromMap_MinimalConfig_UsesChatCompletionDefaultRateLimit() { + var settings = TencentCloudChatCompletionServiceSettings.fromMap( + new HashMap<>(Map.of(ServiceFields.MODEL_ID, "deepseek-v3")), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.modelId(), is("deepseek-v3")); + // Default should be the chat completion specific default (5 rpm), not the common default (20 rpm). + assertThat(settings.rateLimitSettings(), is(TencentCloudChatCompletionServiceSettings.DEFAULT_CHAT_COMPLETION_RATE_LIMIT)); + } + + public void testFromMap_ExplicitRateLimit_Respected() { + var settings = TencentCloudChatCompletionServiceSettings.fromMap( + new HashMap<>( + Map.of( + ServiceFields.MODEL_ID, + "deepseek-v3", + RateLimitSettings.FIELD_NAME, + new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, 42)) + ) + ), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.rateLimitSettings(), is(new RateLimitSettings(42))); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudChatCompletionServiceSettings::new; + } + + @Override + protected TencentCloudChatCompletionServiceSettings createTestInstance() { + return createRandom(); + } + + @Override + protected TencentCloudChatCompletionServiceSettings mutateInstance(TencentCloudChatCompletionServiceSettings instance) + throws IOException { + TencentCloudCommonServiceSettings mutated = randomValueOtherThan( + instance.getCommonSettings(), + TencentCloudCommonServiceSettingsTests::createRandom + ); + return new TencentCloudChatCompletionServiceSettings(mutated); + } + + @Override + protected TencentCloudChatCompletionServiceSettings mutateInstanceForVersion( + TencentCloudChatCompletionServiceSettings instance, + TransportVersion version + ) { + return instance; + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelTests.java new file mode 100644 index 0000000000000..360dbe03e5dd2 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelTests.java @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.inference.SimilarityMeasure; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudEmbeddingsModelTests extends ESTestCase { + + public void testUri_UsesDefaultWhenNoOverride() { + var settings = new TencentCloudEmbeddingsServiceSettings( + new TencentCloudCommonServiceSettings("bge-m3", null, new RateLimitSettings(20)), + SimilarityMeasure.DOT_PRODUCT, + 1024, + 8192 + ); + var model = createModel(settings); + + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); + assertThat(model.getServiceSettings().modelId(), is("bge-m3")); + } + + public void testUri_UsesRegion() { + var settings = new TencentCloudEmbeddingsServiceSettings( + new TencentCloudCommonServiceSettings("bge-m3", "sh", new RateLimitSettings(20)), + null, + null, + null + ); + var model = createModel(settings); + assertThat(model.uri().toString(), is("https://sh.aisearch.tencentelasticsearch.com/v1/embeddings")); + } + + public void testCopyConstructor_UpdatesServiceSettings() { + var original = createModel( + new TencentCloudEmbeddingsServiceSettings( + new TencentCloudCommonServiceSettings("bge-m3", null, new RateLimitSettings(20)), + null, + null, + null + ) + ); + var updated = new TencentCloudEmbeddingsModel( + original, + original.getServiceSettings().updateEmbeddingDetails(1024, SimilarityMeasure.COSINE) + ); + assertThat(updated.getServiceSettings().dimensions(), is(1024)); + assertThat(updated.getServiceSettings().similarity(), is(SimilarityMeasure.COSINE)); + } + + public static TencentCloudEmbeddingsModel createModel(TencentCloudEmbeddingsServiceSettings serviceSettings) { + return new TencentCloudEmbeddingsModel( + "test-inference-id", + serviceSettings, + TencentCloudEmbeddingsTaskSettings.EMPTY_SETTINGS, + null, + new DefaultSecretSettings(new org.elasticsearch.common.settings.SecureString("sk-test".toCharArray())) + ); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettingsTests.java new file mode 100644 index 0000000000000..a283de486a3f9 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettingsTests.java @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.inference.SimilarityMeasure; +import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettingsTests; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudEmbeddingsServiceSettingsTests extends AbstractBWCWireSerializationTestCase< + TencentCloudEmbeddingsServiceSettings> { + + private static final String TEST_MODEL_ID = "bge-m3"; + private static final int TEST_DIMENSIONS = 1024; + private static final int TEST_MAX_INPUT_TOKENS = 512; + + public static TencentCloudEmbeddingsServiceSettings createRandom() { + return new TencentCloudEmbeddingsServiceSettings( + TencentCloudCommonServiceSettingsTests.createRandom(), + randomBoolean() ? randomFrom(SimilarityMeasure.values()) : null, + randomBoolean() ? randomIntBetween(32, TEST_DIMENSIONS) : null, + randomBoolean() ? randomIntBetween(16, TEST_MAX_INPUT_TOKENS) : null + ); + } + + public void testFromMap_MinimalConfig() { + var settings = TencentCloudEmbeddingsServiceSettings.fromMap( + new HashMap<>(Map.of(ServiceFields.MODEL_ID, TEST_MODEL_ID)), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.modelId(), is(TEST_MODEL_ID)); + assertNull(settings.similarity()); + assertNull(settings.dimensions()); + assertNull(settings.maxInputTokens()); + } + + public void testFromMap_AllFields_Success() { + var settings = TencentCloudEmbeddingsServiceSettings.fromMap( + new HashMap<>( + Map.of( + ServiceFields.MODEL_ID, + TEST_MODEL_ID, + ServiceFields.SIMILARITY, + SimilarityMeasure.DOT_PRODUCT.toString(), + ServiceFields.DIMENSIONS, + TEST_DIMENSIONS, + ServiceFields.MAX_INPUT_TOKENS, + TEST_MAX_INPUT_TOKENS, + RateLimitSettings.FIELD_NAME, + new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, 100)) + ) + ), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.modelId(), is(TEST_MODEL_ID)); + assertThat(settings.similarity(), is(SimilarityMeasure.DOT_PRODUCT)); + assertThat(settings.dimensions(), is(TEST_DIMENSIONS)); + assertThat(settings.maxInputTokens(), is(TEST_MAX_INPUT_TOKENS)); + assertThat(settings.rateLimitSettings(), is(new RateLimitSettings(100))); + } + + public void testUpdateEmbeddingDetails_ReturnsCopyWithNewValues() { + var commonSettings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, null, new RateLimitSettings(20)); + var settings = new TencentCloudEmbeddingsServiceSettings(commonSettings, null, null, null); + + var updated = settings.updateEmbeddingDetails(TEST_DIMENSIONS, SimilarityMeasure.COSINE); + + assertThat(updated.dimensions(), is(TEST_DIMENSIONS)); + assertThat(updated.similarity(), is(SimilarityMeasure.COSINE)); + assertThat(updated.getCommonSettings(), is(commonSettings)); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudEmbeddingsServiceSettings::new; + } + + @Override + protected TencentCloudEmbeddingsServiceSettings createTestInstance() { + return createRandom(); + } + + @Override + protected TencentCloudEmbeddingsServiceSettings mutateInstance(TencentCloudEmbeddingsServiceSettings instance) throws IOException { + var commonSettings = instance.getCommonSettings(); + var similarity = instance.similarity(); + var dimensions = instance.dimensions(); + var maxInputTokens = instance.maxInputTokens(); + + switch (between(0, 3)) { + case 0 -> commonSettings = randomValueOtherThan(commonSettings, TencentCloudCommonServiceSettingsTests::createRandom); + case 1 -> similarity = randomValueOtherThan(similarity, () -> randomFrom(SimilarityMeasure.values())); + case 2 -> dimensions = randomValueOtherThan(dimensions, () -> randomIntBetween(32, 4096)); + case 3 -> maxInputTokens = randomValueOtherThan(maxInputTokens, () -> randomIntBetween(16, 8192)); + default -> throw new AssertionError("Illegal randomisation branch"); + } + return new TencentCloudEmbeddingsServiceSettings(commonSettings, similarity, dimensions, maxInputTokens); + } + + @Override + protected TencentCloudEmbeddingsServiceSettings mutateInstanceForVersion( + TencentCloudEmbeddingsServiceSettings instance, + TransportVersion version + ) { + return instance; + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettingsTests.java new file mode 100644 index 0000000000000..3e0e5a3a24c36 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettingsTests.java @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; + +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; +import java.util.HashMap; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudEmbeddingsTaskSettingsTests extends AbstractWireSerializingTestCase { + + public void testFromMap_ReturnsEmptySettings() { + assertThat(TencentCloudEmbeddingsTaskSettings.fromMap(new HashMap<>()), is(TencentCloudEmbeddingsTaskSettings.EMPTY_SETTINGS)); + } + + public void testFromMap_NullMap_ReturnsEmptySettings() { + assertThat(TencentCloudEmbeddingsTaskSettings.fromMap(null), is(TencentCloudEmbeddingsTaskSettings.EMPTY_SETTINGS)); + } + + public void testIsEmpty_AlwaysTrue() { + assertTrue(new TencentCloudEmbeddingsTaskSettings().isEmpty()); + } + + public void testUpdatedTaskSettings_ReturnsSameInstance() { + var settings = new TencentCloudEmbeddingsTaskSettings(); + assertSame(settings, settings.updatedTaskSettings(new HashMap<>())); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudEmbeddingsTaskSettings::new; + } + + @Override + protected TencentCloudEmbeddingsTaskSettings createTestInstance() { + return new TencentCloudEmbeddingsTaskSettings(); + } + + @Override + protected TencentCloudEmbeddingsTaskSettings mutateInstance(TencentCloudEmbeddingsTaskSettings instance) throws IOException { + // No mutable fields available, return null to keep the wire-serialization framework happy. + return null; + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntityTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntityTests.java new file mode 100644 index 0000000000000..a60997b67c798 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntityTests.java @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsTaskSettings; + +import java.io.IOException; +import java.util.List; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudEmbeddingsRequestEntityTests extends ESTestCase { + + public void testXContent_WritesModelAndInputArray() throws IOException { + var model = createEmbeddingsModel("bge-m3"); + var entity = new TencentCloudEmbeddingsRequestEntity(List.of("hello", "world"), model); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + entity.toXContent(builder, null); + + assertThat(Strings.toString(builder), is(""" + {"model":"bge-m3","input":["hello","world"]}""")); + } + + public void testXContent_WithSingleInput() throws IOException { + var model = createEmbeddingsModel("bge-large-zh-v1.5"); + var entity = new TencentCloudEmbeddingsRequestEntity(List.of("你好"), model); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + entity.toXContent(builder, null); + + assertThat(Strings.toString(builder), is(""" + {"model":"bge-large-zh-v1.5","input":["你好"]}""")); + } + + private static TencentCloudEmbeddingsModel createEmbeddingsModel(String modelId) { + var commonSettings = new TencentCloudCommonServiceSettings(modelId, null, new RateLimitSettings(20)); + var serviceSettings = new TencentCloudEmbeddingsServiceSettings(commonSettings, null, null, null); + return new TencentCloudEmbeddingsModel( + "test-inference-id", + serviceSettings, + TencentCloudEmbeddingsTaskSettings.EMPTY_SETTINGS, + null, + new DefaultSecretSettings(new SecureString("sk-test".toCharArray())) + ); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntityTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntityTests.java new file mode 100644 index 0000000000000..fc4a99885e801 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntityTests.java @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.request; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentFactory; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankModel; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankServiceSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankTaskSettings; + +import java.io.IOException; +import java.util.List; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudRerankRequestEntityTests extends ESTestCase { + + public void testXContent_WithRequestLevelParams_TakePrecedenceOverTaskSettings() throws IOException { + var model = createRerankModel("bge-reranker-v2-m3", new TencentCloudRerankTaskSettings(2, false)); + var entity = new TencentCloudRerankRequestEntity("query", List.of("doc1", "doc2"), Boolean.TRUE, 5, model); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + entity.toXContent(builder, null); + + assertThat(Strings.toString(builder), is(""" + {"model":"bge-reranker-v2-m3","query":"query","documents":["doc1","doc2"],"top_n":5,"return_documents":true}""")); + } + + public void testXContent_FallbackToTaskSettings() throws IOException { + var model = createRerankModel("bge-reranker-v2-m3", new TencentCloudRerankTaskSettings(3, true)); + var entity = new TencentCloudRerankRequestEntity("what is ai", List.of("a", "b", "c"), null, null, model); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + entity.toXContent(builder, null); + + assertThat(Strings.toString(builder), is(""" + {"model":"bge-reranker-v2-m3","query":"what is ai","documents":["a","b","c"],"top_n":3,"return_documents":true}""")); + } + + public void testXContent_OnlyRequiredFields() throws IOException { + var model = createRerankModel("bge-reranker-large", TencentCloudRerankTaskSettings.EMPTY_SETTINGS); + var entity = new TencentCloudRerankRequestEntity("q", List.of("d"), null, null, model); + + XContentBuilder builder = XContentFactory.contentBuilder(XContentType.JSON); + entity.toXContent(builder, null); + + assertThat(Strings.toString(builder), is(""" + {"model":"bge-reranker-large","query":"q","documents":["d"]}""")); + } + + private static TencentCloudRerankModel createRerankModel(String modelId, TencentCloudRerankTaskSettings taskSettings) { + var commonSettings = new TencentCloudCommonServiceSettings(modelId, null, new RateLimitSettings(20)); + var serviceSettings = new TencentCloudRerankServiceSettings(commonSettings); + return new TencentCloudRerankModel( + "test-inference-id", + serviceSettings, + taskSettings, + new DefaultSecretSettings(new SecureString("sk-test".toCharArray())) + ); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelTests.java new file mode 100644 index 0000000000000..b8101f9179ec4 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelTests.java @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +public class TencentCloudRerankModelTests extends ESTestCase { + + public void testUri_UsesDefaultWhenNoOverride() { + var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-v2-m3", null, new RateLimitSettings(20))); + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/rerank")); + } + + public void testUri_UsesRegion() { + var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-large", "gz", new RateLimitSettings(20))); + assertThat(model.uri().toString(), is("https://gz.aisearch.tencentelasticsearch.com/v1/rerank")); + } + + public void testOf_EmptyOverride_ReturnsSameInstance() { + var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-v2-m3", null, new RateLimitSettings(20))); + var overridden = TencentCloudRerankModel.of(model, Map.of()); + assertThat(overridden, sameInstance(model)); + } + + public void testOf_MergesTaskSettings() { + var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-v2-m3", null, new RateLimitSettings(20))); + var overridden = TencentCloudRerankModel.of( + model, + new HashMap<>(Map.of(TencentCloudRerankTaskSettings.TOP_N, 5, TencentCloudRerankTaskSettings.RETURN_DOCUMENTS, true)) + ); + assertThat(overridden.getTaskSettings().getTopN(), is(5)); + assertThat(overridden.getTaskSettings().getReturnDocuments(), is(true)); + } + + private static TencentCloudRerankModel createModel(TencentCloudCommonServiceSettings commonSettings) { + return new TencentCloudRerankModel( + "test-inference-id", + new TencentCloudRerankServiceSettings(commonSettings), + TencentCloudRerankTaskSettings.EMPTY_SETTINGS, + new DefaultSecretSettings(new SecureString("sk-test".toCharArray())) + ); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettingsTests.java new file mode 100644 index 0000000000000..d3220302ca6f7 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettingsTests.java @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettingsTests; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudRerankServiceSettingsTests extends AbstractBWCWireSerializationTestCase { + + public static TencentCloudRerankServiceSettings createRandom() { + return new TencentCloudRerankServiceSettings(TencentCloudCommonServiceSettingsTests.createRandom()); + } + + public void testFromMap_MinimalConfig() { + var settings = TencentCloudRerankServiceSettings.fromMap( + new HashMap<>(Map.of(ServiceFields.MODEL_ID, "bge-reranker-v2-m3")), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.modelId(), is("bge-reranker-v2-m3")); + } + + public void testFromMap_WithRateLimit() { + var settings = TencentCloudRerankServiceSettings.fromMap( + new HashMap<>( + Map.of( + ServiceFields.MODEL_ID, + "bge-reranker-large", + RateLimitSettings.FIELD_NAME, + new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, 30)) + ) + ), + ConfigurationParseContext.PERSISTENT + ); + + assertThat(settings.rateLimitSettings(), is(new RateLimitSettings(30))); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudRerankServiceSettings::new; + } + + @Override + protected TencentCloudRerankServiceSettings createTestInstance() { + return createRandom(); + } + + @Override + protected TencentCloudRerankServiceSettings mutateInstance(TencentCloudRerankServiceSettings instance) throws IOException { + return new TencentCloudRerankServiceSettings( + randomValueOtherThan(instance.getCommonSettings(), TencentCloudCommonServiceSettingsTests::createRandom) + ); + } + + @Override + protected TencentCloudRerankServiceSettings mutateInstanceForVersion( + TencentCloudRerankServiceSettings instance, + TransportVersion version + ) { + return instance; + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettingsTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettingsTests.java new file mode 100644 index 0000000000000..c2f868df77352 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettingsTests.java @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; + +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.test.AbstractWireSerializingTestCase; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudRerankTaskSettingsTests extends AbstractWireSerializingTestCase { + + public static TencentCloudRerankTaskSettings createRandom() { + Integer topN = randomBoolean() ? randomIntBetween(1, 20) : null; + Boolean returnDocuments = randomBoolean() ? randomBoolean() : null; + return new TencentCloudRerankTaskSettings(topN, returnDocuments); + } + + public void testFromMap_Empty_ReturnsEmptySettings() { + assertThat(TencentCloudRerankTaskSettings.fromMap(new HashMap<>()), is(TencentCloudRerankTaskSettings.EMPTY_SETTINGS)); + } + + public void testFromMap_NullMap_ReturnsEmptySettings() { + assertThat(TencentCloudRerankTaskSettings.fromMap(null), is(TencentCloudRerankTaskSettings.EMPTY_SETTINGS)); + } + + public void testFromMap_WithFields() { + var settings = TencentCloudRerankTaskSettings.fromMap( + new HashMap<>(Map.of(TencentCloudRerankTaskSettings.TOP_N, 3, TencentCloudRerankTaskSettings.RETURN_DOCUMENTS, true)) + ); + + assertThat(settings.getTopN(), is(3)); + assertThat(settings.getReturnDocuments(), is(true)); + } + + public void testOf_PrefersRequestSettings() { + var original = new TencentCloudRerankTaskSettings(1, false); + var request = new TencentCloudRerankTaskSettings(5, true); + var merged = TencentCloudRerankTaskSettings.of(original, request); + + assertThat(merged.getTopN(), is(5)); + assertThat(merged.getReturnDocuments(), is(true)); + } + + public void testOf_KeepsOriginalWhenRequestIsEmpty() { + var original = new TencentCloudRerankTaskSettings(1, false); + var request = TencentCloudRerankTaskSettings.EMPTY_SETTINGS; + var merged = TencentCloudRerankTaskSettings.of(original, request); + + assertThat(merged.getTopN(), is(1)); + assertThat(merged.getReturnDocuments(), is(false)); + } + + public void testUpdatedTaskSettings() { + var original = new TencentCloudRerankTaskSettings(1, false); + var merged = (TencentCloudRerankTaskSettings) original.updatedTaskSettings( + new HashMap<>(Map.of(TencentCloudRerankTaskSettings.TOP_N, 8)) + ); + assertThat(merged.getTopN(), is(8)); + assertThat(merged.getReturnDocuments(), is(false)); + } + + public void testIsEmpty() { + assertTrue(TencentCloudRerankTaskSettings.EMPTY_SETTINGS.isEmpty()); + assertFalse(new TencentCloudRerankTaskSettings(1, null).isEmpty()); + assertFalse(new TencentCloudRerankTaskSettings(null, true).isEmpty()); + } + + @Override + protected Writeable.Reader instanceReader() { + return TencentCloudRerankTaskSettings::new; + } + + @Override + protected TencentCloudRerankTaskSettings createTestInstance() { + return createRandom(); + } + + @Override + protected TencentCloudRerankTaskSettings mutateInstance(TencentCloudRerankTaskSettings instance) throws IOException { + var topN = instance.getTopN(); + var returnDocuments = instance.getReturnDocuments(); + if (randomBoolean()) { + topN = topN == null ? randomIntBetween(1, 100) : null; + } else { + returnDocuments = returnDocuments == null ? randomBoolean() : null; + } + return new TencentCloudRerankTaskSettings(topN, returnDocuments); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntityTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntityTests.java new file mode 100644 index 0000000000000..fa41c657ef3e4 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntityTests.java @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.response; + +import org.apache.http.HttpResponse; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.inference.external.http.HttpResult; +import org.elasticsearch.xpack.inference.external.http.retry.ErrorResponse; + +import java.nio.charset.StandardCharsets; + +import static org.mockito.Mockito.mock; + +public class TencentCloudErrorResponseEntityTests extends ESTestCase { + + public void testFromResponse_ParsesOpenAiCompatibleErrorObject() { + String responseJson = """ + { + "error": { + "message": "Missing Authorization header", + "type": "invalid_request_error", + "code": "missing_api_key" + } + } + """; + + var errorMessage = TencentCloudErrorResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + assertNotNull(errorMessage); + assertEquals("Missing Authorization header", errorMessage.getErrorMessage()); + } + + public void testFromResponse_FallsBackToTopLevelMessage() { + String responseJson = """ + { + "message": "internal server error" + } + """; + + var errorMessage = TencentCloudErrorResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals("internal server error", errorMessage.getErrorMessage()); + } + + public void testFromResponse_UnknownStructure_ReturnsUndefined() { + String responseJson = """ + {"unexpected":"payload"} + """; + + var errorMessage = TencentCloudErrorResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + assertSame(ErrorResponse.UNDEFINED_ERROR, errorMessage); + } + + public void testFromResponse_MalformedJson_ReturnsUndefined() { + var errorMessage = TencentCloudErrorResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), "not json".getBytes(StandardCharsets.UTF_8)) + ); + + assertSame(ErrorResponse.UNDEFINED_ERROR, errorMessage); + } +} diff --git a/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntityTests.java b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntityTests.java new file mode 100644 index 0000000000000..d1448076f6c91 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntityTests.java @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.inference.services.tencentcloud.response; + +import org.apache.http.HttpResponse; +import org.elasticsearch.inference.InferenceServiceResults; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.inference.results.RankedDocsResults; +import org.elasticsearch.xpack.inference.external.http.HttpResult; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; + +public class TencentCloudRerankResponseEntityTests extends ESTestCase { + + public void testFromResponse_ParsesResultsWithStringDocument() throws IOException { + String responseJson = """ + { + "object": "list", + "results": [ + {"index": 0, "relevance_score": 0.9856, "document": "Artificial intelligence is a branch of computer science"}, + {"index": 2, "relevance_score": 0.8234, "document": "Machine learning is the core technology of AI"}, + {"index": 1, "relevance_score": 0.0123, "document": "The weather is nice today"} + ], + "model": "bge-reranker-v2-m3", + "usage": {"total_tokens": 45} + } + """; + + InferenceServiceResults parsed = TencentCloudRerankResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + assertThat(parsed, instanceOf(RankedDocsResults.class)); + List docs = ((RankedDocsResults) parsed).getRankedDocs(); + assertThat(docs.size(), is(3)); + assertThat(docs.get(0).index(), is(0)); + assertEquals(0.9856f, docs.get(0).relevanceScore(), 0.0001f); + assertThat(docs.get(0).text(), is("Artificial intelligence is a branch of computer science")); + assertThat(docs.get(2).index(), is(1)); + } + + public void testFromResponse_HandlesResultsWithoutDocument() throws IOException { + String responseJson = """ + { + "object": "list", + "results": [ + {"index": 1, "relevance_score": 0.98}, + {"index": 0, "relevance_score": 0.10} + ] + } + """; + + InferenceServiceResults parsed = TencentCloudRerankResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + List docs = ((RankedDocsResults) parsed).getRankedDocs(); + assertThat(docs.size(), is(2)); + assertThat(docs.get(0).index(), is(1)); + assertNull(docs.get(0).text()); + } + + public void testFromResponse_HandlesObjectDocument() throws IOException { + String responseJson = """ + { + "results": [ + {"index": 0, "relevance_score": 0.7, "document": {"text": "hello world"}} + ] + } + """; + + InferenceServiceResults parsed = TencentCloudRerankResponseEntity.fromResponse( + new HttpResult(mock(HttpResponse.class), responseJson.getBytes(StandardCharsets.UTF_8)) + ); + + RankedDocsResults.RankedDoc doc = ((RankedDocsResults) parsed).getRankedDocs().get(0); + assertThat(doc.text(), is("hello world")); + } +}