From a5ee052a26d55f2a42cd157e1bce02f8cf638bbd Mon Sep 17 00:00:00 2001 From: boicehuang Date: Fri, 3 Jul 2026 11:30:46 +0800 Subject: [PATCH 01/15] add tencentcloud inference service --- .../InferenceNamedWriteablesProvider.java | 48 +++ .../xpack/inference/InferencePlugin.java | 2 + .../TencentCloudCommonServiceSettings.java | 169 +++++++++ .../tencentcloud/TencentCloudModel.java | 86 +++++ .../TencentCloudRateLimitServiceSettings.java | 14 + .../TencentCloudResponseHandler.java | 55 +++ .../tencentcloud/TencentCloudService.java | 293 ++++++++++++++++ .../action/TencentCloudActionCreator.java | 85 +++++ .../action/TencentCloudActionVisitor.java | 21 ++ .../TencentCloudChatCompletionModel.java | 106 ++++++ ...encentCloudChatCompletionModelCreator.java | 40 +++ ...centCloudChatCompletionRequestManager.java | 91 +++++ ...entCloudChatCompletionServiceSettings.java | 129 +++++++ .../TencentCloudEmbeddingsModel.java | 106 ++++++ .../TencentCloudEmbeddingsModelCreator.java | 40 +++ ...TencentCloudEmbeddingsServiceSettings.java | 202 +++++++++++ .../TencentCloudEmbeddingsTaskSettings.java | 79 +++++ .../TencentCloudChatCompletionRequest.java | 104 ++++++ .../TencentCloudEmbeddingsRequest.java | 75 ++++ .../TencentCloudEmbeddingsRequestEntity.java | 42 +++ .../request/TencentCloudRequestUtils.java | 25 ++ .../request/TencentCloudRerankRequest.java | 82 +++++ .../TencentCloudRerankRequestEntity.java | 69 ++++ .../request/TencentCloudUtils.java | 21 ++ .../rerank/TencentCloudRerankModel.java | 115 +++++++ .../TencentCloudRerankModelCreator.java | 40 +++ .../TencentCloudRerankServiceSettings.java | 112 ++++++ .../TencentCloudRerankTaskSettings.java | 144 ++++++++ .../TencentCloudErrorResponseEntity.java | 57 ++++ .../TencentCloudRerankResponseEntity.java | 113 ++++++ ...encentCloudCommonServiceSettingsTests.java | 169 +++++++++ .../TencentCloudServiceTests.java | 322 ++++++++++++++++++ .../TencentCloudChatCompletionModelTests.java | 48 +++ ...oudChatCompletionServiceSettingsTests.java | 86 +++++ .../TencentCloudEmbeddingsModelTests.java | 73 ++++ ...ntCloudEmbeddingsServiceSettingsTests.java | 127 +++++++ ...ncentCloudEmbeddingsTaskSettingsTests.java | 52 +++ ...centCloudEmbeddingsRequestEntityTests.java | 63 ++++ .../TencentCloudRerankRequestEntityTests.java | 73 ++++ .../rerank/TencentCloudRerankModelTests.java | 60 ++++ ...encentCloudRerankServiceSettingsTests.java | 79 +++++ .../TencentCloudRerankTaskSettingsTests.java | 98 ++++++ .../TencentCloudErrorResponseEntityTests.java | 73 ++++ ...TencentCloudRerankResponseEntityTests.java | 90 +++++ 44 files changed, 3978 insertions(+) create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudModel.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudRateLimitServiceSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudResponseHandler.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudService.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionCreator.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionVisitor.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModel.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelCreator.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModel.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelCreator.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudChatCompletionRequest.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequest.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntity.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRequestUtils.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequest.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntity.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudUtils.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModel.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelCreator.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettings.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntity.java create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntity.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudServiceTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudEmbeddingsRequestEntityTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequestEntityTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettingsTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudErrorResponseEntityTests.java create mode 100644 x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/response/TencentCloudRerankResponseEntityTests.java 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 fb7d6f5fbe521..6713d43ae206e 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 @@ -136,6 +136,12 @@ 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.tencentcloud.TencentCloudCommonServiceSettings; +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; @@ -195,6 +201,7 @@ public static List getNamedWriteables() { addNvidiaNamedWriteables(namedWriteables); addFireworksAiNamedWriteables(namedWriteables); addDeepSeekNamedWriteables(namedWriteables); + addTencentCloudNamedWriteables(namedWriteables); addUnifiedNamedWriteables(namedWriteables); @@ -224,6 +231,47 @@ private static void addDeepSeekNamedWriteables(List namedWriteables) { + namedWriteables.add( + new NamedWriteableRegistry.Entry( + ServiceSettings.class, + TencentCloudCommonServiceSettings.NAME, + TencentCloudCommonServiceSettings::new + ) + ); + 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( 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 5c030d6fc056e..1ba17fc199ac3 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 @@ -168,6 +168,7 @@ import org.elasticsearch.xpack.inference.services.contextualai.ContextualAiService; import org.elasticsearch.xpack.inference.services.custom.CustomService; import org.elasticsearch.xpack.inference.services.deepseek.DeepSeekService; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import org.elasticsearch.xpack.inference.services.elastic.ElasticInferenceService; import org.elasticsearch.xpack.inference.services.elastic.ElasticInferenceServiceSettings; import org.elasticsearch.xpack.inference.services.elastic.authorization.AuthorizationPoller; @@ -645,6 +646,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..dc30c92edb318 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettings.java @@ -0,0 +1,169 @@ +/* + * 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.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.xcontent.XContentBuilder; +import org.elasticsearch.xpack.inference.services.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.FilteredXContentObject; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractOptionalUri; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractRequiredString; + +/** + * Common service settings shared by all TencentCloud task types. + * Contains the required {@code model_id}, an optional {@code url} override, and rate limit settings. + */ +public class TencentCloudCommonServiceSettings extends FilteredXContentObject + implements + ServiceSettings, + TencentCloudRateLimitServiceSettings { + + public static final String NAME = "tencentcloud_service_settings"; + + // Default rate limit for TencentCloud AI Gateway (see docs). + public static final RateLimitSettings DEFAULT_RATE_LIMIT_SETTINGS = new RateLimitSettings(20); + + @Nullable + public static TencentCloudCommonServiceSettings fromMap( + Map map, + ConfigurationParseContext context, + ValidationException validationException + ) { + int initialValidationErrorCount = validationException.validationErrors().size(); + + var modelId = extractRequiredString(map, ServiceFields.MODEL_ID, ModelConfigurations.SERVICE_SETTINGS, validationException); + var uri = extractOptionalUri(map, ServiceFields.URL, validationException); + var rateLimitSettings = RateLimitSettings.of(map, DEFAULT_RATE_LIMIT_SETTINGS, validationException, context); + + if (validationException.validationErrors().size() > initialValidationErrorCount) { + return null; + } + return new TencentCloudCommonServiceSettings(modelId, uri, rateLimitSettings); + } + + private final String modelId; + @Nullable + private final URI uri; + private final RateLimitSettings rateLimitSettings; + + public TencentCloudCommonServiceSettings(String modelId, @Nullable URI uri, @Nullable RateLimitSettings rateLimitSettings) { + this.modelId = Objects.requireNonNull(modelId); + this.uri = uri; + this.rateLimitSettings = Objects.requireNonNullElse(rateLimitSettings, DEFAULT_RATE_LIMIT_SETTINGS); + } + + public TencentCloudCommonServiceSettings(StreamInput in) throws IOException { + this.modelId = in.readString(); + var uriString = in.readOptionalString(); + this.uri = uriString == null ? null : URI.create(uriString); + 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.uri, extractedRateLimitSettings); + } + + @Override + public String modelId() { + return modelId; + } + + @Nullable + public URI uri() { + return uri; + } + + @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); + if (uri != null) { + builder.field(ServiceFields.URL, uri.toString()); + } + rateLimitSettings.toXContent(builder, params); + return builder; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TransportVersion.minimumCompatible(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(modelId); + out.writeOptionalString(uri != null ? uri.toString() : null); + 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(uri, that.uri) + && Objects.equals(rateLimitSettings, that.rateLimitSettings); + } + + @Override + public int hashCode() { + return Objects.hash(modelId, uri, 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..3c974dfef8a5d --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudService.java @@ -0,0 +1,293 @@ +/* + * 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.sender.EmbeddingsInput; +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.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.completion.TencentCloudChatCompletionRequestManager; +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.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.ServiceFields.URL; +import static org.elasticsearch.xpack.inference.services.ServiceUtils.createInvalidModelException; + +/** + * Inference service integration for the TencentCloud AI Gateway (OpenAI-compatible), supporting {@code text_embedding}, + * {@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"; + + // 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.CHAT_COMPLETION, + TaskType.RERANK + ); + private static final EnumSet SUPPORTED_STREAMING_TASKS = EnumSet.of(TaskType.CHAT_COMPLETION); + + private static final Map> MODEL_CREATORS = Map.of( + TaskType.TEXT_EMBEDDING, + new TencentCloudEmbeddingsModelCreator(), + TaskType.CHAT_COMPLETION, + new TencentCloudChatCompletionModelCreator(), + 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 TencentCloudChatCompletionModel chatModel) { + var requestManager = new TencentCloudChatCompletionRequestManager(chatModel, getServiceComponents().threadPool()); + var errorMessage = constructFailedToSendRequestMessage("TencentCloud completions"); + var action = new SenderExecutableAction(getSender(), requestManager, errorMessage); + action.execute(inputs, timeout, listener); + return; + } + + 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 TencentCloudChatCompletionRequestManager(chatModel, getServiceComponents().threadPool()); + 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 TransportVersion.minimumCompatible(); + } + + @Override + public int rerankerWindowSize(String modelId) { + // BGE reranker models (bge-reranker-large, bge-reranker-v2-m3) support up to 512-token inputs. + // Using 1 token = 0.75 words, that is roughly 384 words. Use a conservative value with headroom. + 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, deepseek-v3, bge-reranker-v2-m3." + ) + .setLabel("Model ID") + .setRequired(true) + .setSensitive(false) + .setUpdatable(false) + .setType(SettingsConfigurationFieldType.STRING) + .build() + ); + + configurationMap.put( + URL, + new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDefaultValue( + "http://bj.aisearch.tencentelasticsearch.com/v1/embeddings" + ) + .setDescription( + "The full URL endpoint to use for the requests. Defaults to the AI Gateway endpoint corresponding to the " + + "task type." + ) + .setLabel("URL") + .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..2c259c8087c5e --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionCreator.java @@ -0,0 +1,85 @@ +/* + * 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.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.QueryAndDocsInputs; +import org.elasticsearch.xpack.inference.external.http.sender.Sender; +import org.elasticsearch.xpack.inference.services.ServiceComponents; +import org.elasticsearch.xpack.inference.services.openai.response.OpenAiEmbeddingsResponseEntity; +import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudResponseHandler; +import org.elasticsearch.xpack.inference.services.tencentcloud.embeddings.TencentCloudEmbeddingsModel; +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 and rerank models. + * Chat completion is handled directly by the {@code TencentCloudService} via a dedicated request manager (not this visitor). + */ +public class TencentCloudActionCreator implements TencentCloudActionVisitor { + + 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 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")); + } +} 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..2787f2f6da146 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/action/TencentCloudActionVisitor.java @@ -0,0 +1,21 @@ +/* + * 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.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); +} 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..b9faaca3cd3a9 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModel.java @@ -0,0 +1,106 @@ +/* + * 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.apache.http.client.utils.URIBuilder; +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; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; + +public class TencentCloudChatCompletionModel extends TencentCloudModel { + + private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) + .setHost(TencentCloudUtils.HOST) + .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.CHAT_COMPLETIONS_PATH_1, TencentCloudUtils.CHAT_COMPLETIONS_PATH_2); + + 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(); + } + + /** + * Chat completion is dispatched via the {@link TencentCloudService#doInfer} / {@link TencentCloudService#doUnifiedCompletionInfer} + * paths using a dedicated {@code TencentCloudChatCompletionRequestManager}, so this visitor entry is not used. + */ + @Override + public ExecutableAction accept(TencentCloudActionVisitor visitor, Map taskSettings) { + throw new UnsupportedOperationException( + "TencentCloud chat completion does not use the visitor-based action creation flow; " + + "requests are dispatched directly through the request manager." + ); + } + + private static URI resolveUri(TencentCloudChatCompletionServiceSettings serviceSettings) { + var override = serviceSettings.getCommonSettings().uri(); + return Objects.requireNonNullElseGet(override, () -> buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + } +} 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/TencentCloudChatCompletionRequestManager.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java new file mode 100644 index 0000000000000..ab85db10cc675 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java @@ -0,0 +1,91 @@ +/* + * 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.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.inference.InferenceServiceResults; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.inference.external.http.retry.RequestSender; +import org.elasticsearch.xpack.inference.external.http.retry.ResponseHandler; +import org.elasticsearch.xpack.inference.external.http.sender.BaseRequestManager; +import org.elasticsearch.xpack.inference.external.http.sender.ChatCompletionInput; +import org.elasticsearch.xpack.inference.external.http.sender.ExecutableInferenceRequest; +import org.elasticsearch.xpack.inference.external.http.sender.InferenceInputs; +import org.elasticsearch.xpack.inference.external.http.sender.UnifiedChatInput; +import org.elasticsearch.xpack.inference.services.openai.OpenAiChatCompletionResponseHandler; +import org.elasticsearch.xpack.inference.services.openai.OpenAiUnifiedChatCompletionResponseHandler; +import org.elasticsearch.xpack.inference.services.openai.response.OpenAiChatCompletionResponseEntity; +import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudChatCompletionRequest; + +import java.util.Objects; +import java.util.function.Supplier; + +import static org.elasticsearch.xpack.inference.external.http.sender.InferenceInputs.createUnsupportedTypeException; + +/** + * Dispatches TencentCloud AI Gateway chat completion requests. TencentCloud is OpenAI-compatible so the OpenAI + * response handlers are reused. + */ +public class TencentCloudChatCompletionRequestManager extends BaseRequestManager { + + private static final Logger logger = LogManager.getLogger(TencentCloudChatCompletionRequestManager.class); + + private static final ResponseHandler CHAT_COMPLETION = new OpenAiUnifiedChatCompletionResponseHandler( + "tencentcloud chat completion", + OpenAiChatCompletionResponseEntity::fromResponse + ); + + private static final ResponseHandler COMPLETION = new OpenAiChatCompletionResponseHandler( + "tencentcloud completion", + OpenAiChatCompletionResponseEntity::fromResponse + ); + + private final TencentCloudChatCompletionModel model; + + public TencentCloudChatCompletionRequestManager(TencentCloudChatCompletionModel model, ThreadPool threadPool) { + super(threadPool, model); + this.model = Objects.requireNonNull(model); + } + + @Override + public void execute( + InferenceInputs inferenceInputs, + RequestSender requestSender, + Supplier hasRequestCompletedFunction, + ActionListener listener + ) { + switch (inferenceInputs) { + case UnifiedChatInput uci -> execute(uci, requestSender, hasRequestCompletedFunction, listener); + case ChatCompletionInput cci -> execute(cci, requestSender, hasRequestCompletedFunction, listener); + default -> throw createUnsupportedTypeException(inferenceInputs, UnifiedChatInput.class); + } + } + + private void execute( + UnifiedChatInput inferenceInputs, + RequestSender requestSender, + Supplier hasRequestCompletedFunction, + ActionListener listener + ) { + var request = new TencentCloudChatCompletionRequest(inferenceInputs, model); + execute(new ExecutableInferenceRequest(requestSender, logger, request, CHAT_COMPLETION, hasRequestCompletedFunction, listener)); + } + + private void execute( + ChatCompletionInput inferenceInputs, + RequestSender requestSender, + Supplier hasRequestCompletedFunction, + ActionListener listener + ) { + var unifiedInputs = new UnifiedChatInput(inferenceInputs.getInputs(), "user", inferenceInputs.stream()); + var request = new TencentCloudChatCompletionRequest(unifiedInputs, model); + execute(new ExecutableInferenceRequest(requestSender, logger, request, COMPLETION, hasRequestCompletedFunction, listener)); + } +} 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..ccad9ef80d5d5 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java @@ -0,0 +1,129 @@ +/* + * 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.ServiceSettings; +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 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); + + public static TencentCloudChatCompletionServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var validationException = new ValidationException(); + + // Chat completion has a lower default rate limit than embeddings/rerank; if user does not specify one we override the default. + if (map != null && map.containsKey(RateLimitSettings.FIELD_NAME) == false) { + // Override common default before parsing. + } + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, validationException); + // If the user did not provide a rate_limit override, replace the common default (20 rpm) with 5 rpm. + if (commonSettings != null && commonSettings.rateLimitSettings() == TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS) { + commonSettings = new TencentCloudCommonServiceSettings( + commonSettings.modelId(), + commonSettings.uri(), + DEFAULT_CHAT_COMPLETION_RATE_LIMIT + ); + } + + 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 TransportVersion.minimumCompatible(); + } + + @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); + } +} 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..ef36bd831631b --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModel.java @@ -0,0 +1,106 @@ +/* + * 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.apache.http.client.utils.URIBuilder; +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; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; + +public class TencentCloudEmbeddingsModel extends TencentCloudModel { + + private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) + .setHost(TencentCloudUtils.HOST) + .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.EMBEDDINGS_PATH); + + 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) { + var override = serviceSettings.getCommonSettings().uri(); + return Objects.requireNonNullElseGet(override, () -> buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + } +} 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..1b4097fe9858c --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java @@ -0,0 +1,202 @@ +/* + * 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.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 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"; + + public static TencentCloudEmbeddingsServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var validationException = new ValidationException(); + + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, validationException); + 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 TransportVersion.minimumCompatible(); + } + + @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); + } +} 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..a9d0acd297001 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsTaskSettings.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.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 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 TransportVersion.minimumCompatible(); + } + + @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..dcb6071165965 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudRerankRequest.java @@ -0,0 +1,82 @@ +/* + * 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..7940d92661f8b --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/request/TencentCloudUtils.java @@ -0,0 +1,21 @@ +/* + * 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; + +public final class TencentCloudUtils { + + public static final String SCHEME = "http"; + public static final String HOST = "bj.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() {} +} 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..3f9faefc31d06 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModel.java @@ -0,0 +1,115 @@ +/* + * 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.apache.http.client.utils.URIBuilder; +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; +import java.util.Objects; + +import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; + +public class TencentCloudRerankModel extends TencentCloudModel { + + private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) + .setHost(TencentCloudUtils.HOST) + .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.RERANK_PATH); + + 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) { + var override = serviceSettings.getCommonSettings().uri(); + return Objects.requireNonNullElseGet(override, () -> buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + } +} 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..7f1f06de7e35b --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java @@ -0,0 +1,112 @@ +/* + * 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.ServiceSettings; +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 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"; + + public static TencentCloudRerankServiceSettings fromMap(Map map, ConfigurationParseContext context) { + var validationException = new ValidationException(); + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, 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 TransportVersion.minimumCompatible(); + } + + @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); + } +} 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..f1032e0d7c6fc --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankTaskSettings.java @@ -0,0 +1,144 @@ +/* + * 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 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 TransportVersion.minimumCompatible(); + } + + @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..f4e12016df3b8 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudCommonServiceSettingsTests.java @@ -0,0 +1,169 @@ +/* + * 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.ConfigurationParseContext; +import org.elasticsearch.xpack.inference.services.ServiceFields; +import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; + +import java.io.IOException; +import java.net.URI; +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_URL = "http://custom.example.com/v1/embeddings"; + 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); + URI uri = randomBoolean() ? URI.create("http://" + randomAlphaOfLength(8) + "/v1/embeddings") : null; + var rateLimitSettings = randomBoolean() ? new RateLimitSettings(randomIntBetween(1, 1000)) : null; + return new TencentCloudCommonServiceSettings(modelId, uri, rateLimitSettings); + } + + public void testFromMap_MinimalConfig_UsesDefaults() { + var validationException = new ValidationException(); + var settings = TencentCloudCommonServiceSettings.fromMap( + new HashMap<>(Map.of(ServiceFields.MODEL_ID, TEST_MODEL_ID)), + ConfigurationParseContext.PERSISTENT, + validationException + ); + + assertThat(validationException.validationErrors().size(), is(0)); + assertThat(settings.modelId(), is(TEST_MODEL_ID)); + assertNull(settings.uri()); + assertThat(settings.rateLimitSettings(), is(TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS)); + } + + public void testFromMap_WithUrlAndRateLimit_Success() { + var validationException = new ValidationException(); + var settings = TencentCloudCommonServiceSettings.fromMap( + new HashMap<>( + Map.of( + ServiceFields.MODEL_ID, + TEST_MODEL_ID, + ServiceFields.URL, + TEST_URL, + RateLimitSettings.FIELD_NAME, + new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, TEST_RATE_LIMIT)) + ) + ), + ConfigurationParseContext.PERSISTENT, + validationException + ); + + assertThat(settings.modelId(), is(TEST_MODEL_ID)); + assertThat(settings.uri(), is(URI.create(TEST_URL))); + assertThat(settings.rateLimitSettings(), is(new RateLimitSettings(TEST_RATE_LIMIT))); + } + + public void testFromMap_MissingModelId_AddsValidationError() { + var validationException = new ValidationException(); + TencentCloudCommonServiceSettings.fromMap(new HashMap<>(), ConfigurationParseContext.PERSISTENT, validationException); + assertThat(validationException.validationErrors().size(), is(1)); + } + + public void testUpdateServiceSettings_OnlyRateLimitIsMutable() { + var originalSettings = new TencentCloudCommonServiceSettings( + INITIAL_TEST_MODEL_ID, + URI.create(TEST_URL), + 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 uri are immutable + assertThat(updatedSettings.modelId(), is(INITIAL_TEST_MODEL_ID)); + assertThat(updatedSettings.uri(), is(URI.create(TEST_URL))); + assertThat(updatedSettings.rateLimitSettings(), is(new RateLimitSettings(TEST_RATE_LIMIT))); + } + + public void testUpdateServiceSettings_EmptyMap_DoesNotChangeSettings() { + var originalSettings = new TencentCloudCommonServiceSettings( + INITIAL_TEST_MODEL_ID, + null, + new RateLimitSettings(INITIAL_TEST_RATE_LIMIT) + ); + var updatedSettings = originalSettings.updateCommonServiceSettings(new HashMap<>(), new ValidationException()); + assertThat(updatedSettings, is(originalSettings)); + } + + public void testXContent_WithUrl() throws IOException { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, URI.create(TEST_URL), 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","url":"%s","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_URL, TEST_RATE_LIMIT))); + } + + public void testXContent_WithoutUrl() 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","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 uri = instance.uri(); + var rateLimitSettings = instance.rateLimitSettings(); + + switch (between(0, 2)) { + case 0 -> modelId = randomValueOtherThan(modelId, () -> randomAlphaOfLength(8)); + case 1 -> uri = uri == null ? URI.create("http://" + randomAlphaOfLength(6) + "/v1") : null; + case 2 -> rateLimitSettings = randomValueOtherThan( + rateLimitSettings, + () -> new RateLimitSettings(randomIntBetween(1, 1000)) + ); + default -> throw new AssertionError("Illegal randomisation branch"); + } + return new TencentCloudCommonServiceSettings(modelId, uri, 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..ff5fd65e88fd3 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudServiceTests.java @@ -0,0 +1,322 @@ +/* + * 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.InferenceServiceResults; +import org.elasticsearch.inference.InputType; +import org.elasticsearch.inference.Model; +import org.elasticsearch.inference.ModelConfigurations; +import org.elasticsearch.inference.ModelSecrets; +import org.elasticsearch.inference.ServiceSettings; +import org.elasticsearch.inference.TaskType; +import org.elasticsearch.inference.UnifiedCompletionRequest; +import org.elasticsearch.inference.UnparsedModel; +import org.elasticsearch.inference.completion.ContentString; +import org.elasticsearch.inference.completion.Message; +import org.elasticsearch.test.http.MockResponse; +import org.elasticsearch.xcontent.XContentParserConfiguration; +import org.elasticsearch.xcontent.XContentType; +import org.elasticsearch.xpack.core.inference.results.ChatCompletionResults; +import org.elasticsearch.xpack.inference.external.http.sender.HttpRequestSenderTests; +import org.elasticsearch.xpack.inference.services.InferenceEventsAssertion; +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.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.URI; +import java.nio.charset.StandardCharsets; +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.external.http.Utils.getUrl; +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; + +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.CHAT_COMPLETION), is(true)); + assertThat(service.supportedTaskTypes().contains(TaskType.RERANK), is(true)); + } + } + + public void testParseRequestConfig_TextEmbedding() throws IOException { + parseRequestConfig(TaskType.TEXT_EMBEDDING, format(""" + { + "service_settings": { + "api_key": "sk-12345", + "model_id": "bge-m3", + "url": "%s" + } + } + """, getUrl(webServer)), assertNoFailureListener(model -> { + assertThat(model, isA(TencentCloudEmbeddingsModel.class)); + var m = (TencentCloudEmbeddingsModel) model; + assertThat(m.getServiceSettings().modelId(), equalTo("bge-m3")); + assertThat(m.uri(), equalTo(URI.create(getUrl(webServer)))); + assertThat(m.apiKey().toString(), equalTo("sk-12345")); + })); + } + + public void testParseRequestConfig_ChatCompletion_UsesDefaultUrl() throws IOException { + parseRequestConfig(TaskType.CHAT_COMPLETION, """ + { + "service_settings": { + "api_key": "sk-12345", + "model_id": "deepseek-v3" + } + } + """, assertNoFailureListener(model -> { + assertThat(model, isA(TencentCloudChatCompletionModel.class)); + var m = (TencentCloudChatCompletionModel) model; + assertThat(m.model(), equalTo("deepseek-v3")); + assertThat(m.uri().toString(), equalTo("http://bj.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", + "url": "http://localhost:989/v1/chat/completions" + } + } + """); + 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(), equalTo(URI.create("http://localhost:989/v1/chat/completions"))); + } + } + + public void testDoInferChatCompletion_NonStream() throws Exception { + webServer.enqueue(new MockResponse().setResponseCode(200).setBody(""" + {"choices": [{"message": {"content": "hello, world", "role": "assistant"}, "finish_reason": "stop", "index": 0}], \ + "created": 1718345013, "id": "12345", "model": "deepseek-v3", "object": "chat.completion"}""")); + try (var service = createService()) { + var model = createChatCompletionModel(TaskType.COMPLETION); + TestPlainActionFuture listener = new TestPlainActionFuture<>(); + service.infer(model, List.of("hello"), false, Map.of(), InputType.UNSPECIFIED, TIMEOUT, listener); + var result = listener.actionGet(TIMEOUT); + assertThat(result, isA(ChatCompletionResults.class)); + var completion = (ChatCompletionResults) result; + assertThat( + completion.results().stream().map(ChatCompletionResults.Result::predictedValue).toList(), + equalTo(List.of("hello, world")) + ); + } + } + + public void testDoUnifiedInfer_Stream() throws Exception { + webServer.enqueue(new MockResponse().setResponseCode(200).setBody(""" + data: {"choices": [{"delta": {"content": "hello", "role": "assistant"}, "finish_reason": null, "index": 0}], \ + "created": 1718345013, "id": "12345", "model": "deepseek-v3", "object": "chat.completion.chunk"} + + data: [DONE] + + """)); + try (var service = createService()) { + var model = createChatCompletionModel(TaskType.CHAT_COMPLETION); + TestPlainActionFuture listener = new TestPlainActionFuture<>(); + service.unifiedCompletionInfer( + model, + UnifiedCompletionRequest.of(List.of(new Message(new ContentString("hi"), "user", null, null))), + TIMEOUT, + listener + ); + InferenceEventsAssertion.assertThat(listener.actionGet(TIMEOUT)) + .hasFinishedStream() + .hasNoErrors() + .hasEvent(""" + {"id":"12345","choices":[{"delta":{"content":"hello","role":"assistant"},"index":0}],""" + """ + "model":"deepseek-v3","object":"chat.completion.chunk"}"""); + } + } + + public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOException { + try (var service = createService()) { + TestPlainActionFuture> listener = new TestPlainActionFuture<>(); + service.chunkedInfer( + mock(TencentCloudChatCompletionModel.class), + List.of(new ChunkInferenceInput("a")), + Map.of(), + InputType.UNSPECIFIED, + TIMEOUT, + listener + ); + expectThrows(ElasticsearchStatusException.class, () -> listener.actionGet(TIMEOUT)); + } + } + + public void testBuildModelFromConfigAndSecrets_UnsupportedTaskType_Fails() 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)); + } + } + + private TencentCloudService createService() { + return new TencentCloudService( + HttpRequestSenderTests.createSenderFactory(threadPool, clientManager), + createWithEmptySettings(threadPool), + mockClusterServiceEmpty() + ); + } + + @Override + public InferenceService createInferenceService() { + return createService(); + } + + private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskType) { + var commonSettings = new TencentCloudCommonServiceSettings( + "deepseek-v3", + webServer.getUri(null), + 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) { + var commonSettings = new TencentCloudCommonServiceSettings( + modelId, + webServer.getUri(null), + 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..65afe24f60ae6 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionModelTests.java @@ -0,0 +1,48 @@ +/* + * 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 java.net.URI; + +import static org.hamcrest.Matchers.is; + +public class TencentCloudChatCompletionModelTests extends ESTestCase { + + public void testUri_UsesDefaultWhenNoOverride() { + var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", null, new RateLimitSettings(5))); + assertThat(model.uri().toString(), is("http://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); + } + + public void testUri_UsesOverrideWhenProvided() { + var override = URI.create("http://custom.example.com/v1/chat/completions"); + var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", override, new RateLimitSettings(5))); + assertThat(model.uri(), is(override)); + } + + 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..3b522f34defc4 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsModelTests.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.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 java.net.URI; + +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("http://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); + assertThat(model.getServiceSettings().modelId(), is("bge-m3")); + } + + public void testUri_UsesOverrideWhenProvided() { + var override = URI.create("http://custom.example.com/embeddings"); + var settings = new TencentCloudEmbeddingsServiceSettings( + new TencentCloudCommonServiceSettings("bge-m3", override, new RateLimitSettings(20)), + null, + null, + null + ); + var model = createModel(settings); + assertThat(model.uri(), is(override)); + } + + 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..e987381ffac6c --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettingsTests.java @@ -0,0 +1,127 @@ +/* + * 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 { + + 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..ad14bccc4a286 --- /dev/null +++ b/x-pack/plugin/inference/src/test/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankModelTests.java @@ -0,0 +1,60 @@ +/* + * 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.net.URI; +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("http://bj.aisearch.tencentelasticsearch.com/v1/rerank")); + } + + public void testUri_UsesOverrideWhenProvided() { + var override = URI.create("http://custom.example.com/v1/rerank"); + var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-large", override, new RateLimitSettings(20))); + assertThat(model.uri(), is(override)); + } + + 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")); + } +} From 2d97cbb9dbe905263a304cf8b856d0be950be43b Mon Sep 17 00:00:00 2001 From: boicehuang Date: Wed, 8 Jul 2026 10:55:05 +0800 Subject: [PATCH 02/15] add SSRF guard --- .../tencentcloud/TencentCloudCommonServiceSettings.java | 2 ++ .../inference/services/tencentcloud/TencentCloudService.java | 2 +- .../services/tencentcloud/request/TencentCloudUtils.java | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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 index dc30c92edb318..e95cd84410a7a 100644 --- 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 @@ -52,6 +52,8 @@ public static TencentCloudCommonServiceSettings fromMap( var modelId = extractRequiredString(map, ServiceFields.MODEL_ID, ModelConfigurations.SERVICE_SETTINGS, validationException); var uri = extractOptionalUri(map, ServiceFields.URL, validationException); + // SSRF guard: when the caller overrides the endpoint, ensure it uses https and points to a Tencent-hosted allow-listed host. + TencentCloudEndpointUtils.validateEndpoint(uri, ServiceFields.URL, ModelConfigurations.SERVICE_SETTINGS, validationException); var rateLimitSettings = RateLimitSettings.of(map, DEFAULT_RATE_LIMIT_SETTINGS, validationException, context); if (validationException.validationErrors().size() > initialValidationErrorCount) { 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 index 3c974dfef8a5d..ca1d21b05f801 100644 --- 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 @@ -260,7 +260,7 @@ public static InferenceServiceConfiguration get() { configurationMap.put( URL, new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDefaultValue( - "http://bj.aisearch.tencentelasticsearch.com/v1/embeddings" + "https://bj.aisearch.tencentelasticsearch.com/v1/embeddings" ) .setDescription( "The full URL endpoint to use for the requests. Defaults to the AI Gateway endpoint corresponding to the " 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 index 7940d92661f8b..99f90978631f4 100644 --- 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 @@ -9,7 +9,7 @@ public final class TencentCloudUtils { - public static final String SCHEME = "http"; + public static final String SCHEME = "https"; public static final String HOST = "bj.aisearch.tencentelasticsearch.com"; public static final String VERSION_1 = "v1"; public static final String EMBEDDINGS_PATH = "embeddings"; From 2f4d69fd8860f4cb4801e276141482f641ccd7f8 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Wed, 8 Jul 2026 19:01:55 +0800 Subject: [PATCH 03/15] add SSRF guard --- .../TencentCloudEndpointUtils.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java new file mode 100644 index 0000000000000..038f160161734 --- /dev/null +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.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; + +import org.elasticsearch.common.ValidationException; + +import java.net.URI; +import java.util.Locale; + +/** + * Validates that a user-supplied endpoint URI for the TencentCloud AI Gateway inference service points to an + * approved Tencent-hosted host. This is the SSRF guard: because the framework only performs URI syntax parsing + * (see {@code ServiceUtils.convertToUri}) and the shared HTTP client accepts arbitrary hosts, each service that + * exposes a user-overridable URL must enforce its own allow-list here, before any secret is bound to a request. + */ +public final class TencentCloudEndpointUtils { + + private static final String TENCENT_ES_SUFFIX = ".tencentelasticsearch.com"; + private static final String TENCENT_ES_ROOT = "tencentelasticsearch.com"; + + private TencentCloudEndpointUtils() {} + + public static URI validateEndpoint(URI uri, String fieldName, String scope, ValidationException validationException) { + if (uri == null) { + return null; + } + String scheme = uri.getScheme(); + if ("https".equalsIgnoreCase(scheme) == false) { + validationException.addValidationError( + String.format(Locale.ROOT, "[%s] in [%s] must use the [https] scheme", fieldName, scope) + ); + return uri; + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + validationException.addValidationError( + String.format(Locale.ROOT, "[%s] in [%s] must include a host", fieldName, scope) + ); + return uri; + } + String normalizedHost = normalizeHost(host); + // SSRF protection relies on the allow-list below: the host must be a *.tencentelasticsearch.com host. + // Any IP literal (private or public), localhost, or other non-Tencent host therefore fails this check, + // so there is no need for a separate blocked-host list. + if (isTencentElasticsearchHost(normalizedHost) == false) { + validationException.addValidationError( + String.format( + Locale.ROOT, + "[%s] in [%s] must point to a TencentCloud AI Gateway host ending with [%s]", + fieldName, + scope, + TENCENT_ES_SUFFIX + ) + ); + } + return uri; + } + + private static String normalizeHost(String host) { + String normalized = host.toLowerCase(Locale.ROOT); + if (normalized.endsWith(".")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static boolean isTencentElasticsearchHost(String host) { + return host.equals(TENCENT_ES_ROOT) || host.endsWith(TENCENT_ES_SUFFIX); + } +} From 87a0129cabc4aaf2d7151ca6cffcedb8ada79627 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Wed, 8 Jul 2026 19:17:03 +0800 Subject: [PATCH 04/15] add change log --- docs/changelog/152829.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/152829.yaml diff --git a/docs/changelog/152829.yaml b/docs/changelog/152829.yaml new file mode 100644 index 0000000000000..bea196936cb76 --- /dev/null +++ b/docs/changelog/152829.yaml @@ -0,0 +1,5 @@ +pr: 152829 +summary: Add Tencent Cloud inference service +area: Machine Learning +type: feature +issues: [] From 59d89c524d24487c60387fe9784927783d5802a6 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Wed, 8 Jul 2026 16:04:00 +0000 Subject: [PATCH 05/15] [CI] Auto commit changes from spotless --- .../InferenceNamedWriteablesProvider.java | 7 ------- .../xpack/inference/InferencePlugin.java | 2 +- .../tencentcloud/TencentCloudEndpointUtils.java | 4 +--- .../embeddings/TencentCloudEmbeddingsModel.java | 9 ++++++++- .../request/TencentCloudRerankRequest.java | 3 ++- .../TencentCloudCommonServiceSettingsTests.java | 5 +---- .../tencentcloud/TencentCloudServiceTests.java | 14 +++++++------- ...TencentCloudEmbeddingsServiceSettingsTests.java | 8 +++----- 8 files changed, 23 insertions(+), 29 deletions(-) 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 6bbfc6e5483f9..fb7d6f5fbe521 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 @@ -136,13 +136,6 @@ 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.tencentcloud.TencentCloudCommonServiceSettings; -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.settings.ImmutableEmptyTaskSettings; 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; 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 535a2b5a100fa..64e30a69337ed 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 @@ -169,7 +169,6 @@ import org.elasticsearch.xpack.inference.services.contextualai.ContextualAiService; import org.elasticsearch.xpack.inference.services.custom.CustomService; import org.elasticsearch.xpack.inference.services.deepseek.DeepSeekService; -import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import org.elasticsearch.xpack.inference.services.elastic.ElasticInferenceService; import org.elasticsearch.xpack.inference.services.elastic.ElasticInferenceServiceSettings; import org.elasticsearch.xpack.inference.services.elastic.authorization.AuthorizationPoller; @@ -203,6 +202,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; diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java index 038f160161734..6837fb3df7a8a 100644 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java +++ b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java @@ -38,9 +38,7 @@ public static URI validateEndpoint(URI uri, String fieldName, String scope, Vali } String host = uri.getHost(); if (host == null || host.isBlank()) { - validationException.addValidationError( - String.format(Locale.ROOT, "[%s] in [%s] must include a host", fieldName, scope) - ); + validationException.addValidationError(String.format(Locale.ROOT, "[%s] in [%s] must include a host", fieldName, scope)); return uri; } String normalizedHost = normalizeHost(host); 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 index ef36bd831631b..93b0028bc88b6 100644 --- 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 @@ -57,7 +57,14 @@ public TencentCloudEmbeddingsModel( @Nullable DefaultSecretSettings secretSettings ) { super( - new ModelConfigurations(inferenceId, TaskType.TEXT_EMBEDDING, TencentCloudService.NAME, serviceSettings, taskSettings, chunkingSettings), + new ModelConfigurations( + inferenceId, + TaskType.TEXT_EMBEDDING, + TencentCloudService.NAME, + serviceSettings, + taskSettings, + chunkingSettings + ), new ModelSecrets(secretSettings), secretSettings, serviceSettings.getCommonSettings(), 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 index dcb6071165965..724fc241c3f08 100644 --- 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 @@ -51,7 +51,8 @@ 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) + Strings.toString(new TencentCloudRerankRequestEntity(query, input, returnDocuments, topN, model)) + .getBytes(StandardCharsets.UTF_8) ); httpPost.setEntity(byteEntity); 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 index f4e12016df3b8..7e19ec3149ac9 100644 --- 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 @@ -153,10 +153,7 @@ protected TencentCloudCommonServiceSettings mutateInstance(TencentCloudCommonSer switch (between(0, 2)) { case 0 -> modelId = randomValueOtherThan(modelId, () -> randomAlphaOfLength(8)); case 1 -> uri = uri == null ? URI.create("http://" + randomAlphaOfLength(6) + "/v1") : null; - case 2 -> rateLimitSettings = randomValueOtherThan( - rateLimitSettings, - () -> new RateLimitSettings(randomIntBetween(1, 1000)) - ); + case 2 -> rateLimitSettings = randomValueOtherThan(rateLimitSettings, () -> new RateLimitSettings(randomIntBetween(1, 1000))); default -> throw new AssertionError("Illegal randomisation branch"); } return new TencentCloudCommonServiceSettings(modelId, uri, rateLimitSettings); 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 index ff5fd65e88fd3..eb231636b893c 100644 --- 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 @@ -219,12 +219,9 @@ public void testDoUnifiedInfer_Stream() throws Exception { TIMEOUT, listener ); - InferenceEventsAssertion.assertThat(listener.actionGet(TIMEOUT)) - .hasFinishedStream() - .hasNoErrors() - .hasEvent(""" - {"id":"12345","choices":[{"delta":{"content":"hello","role":"assistant"},"index":0}],""" + """ - "model":"deepseek-v3","object":"chat.completion.chunk"}"""); + InferenceEventsAssertion.assertThat(listener.actionGet(TIMEOUT)).hasFinishedStream().hasNoErrors().hasEvent(""" + {"id":"12345","choices":[{"delta":{"content":"hello","role":"assistant"},"index":0}],""" + """ + "model":"deepseek-v3","object":"chat.completion.chunk"}"""); } } @@ -255,7 +252,10 @@ public void testBuildModelFromConfigAndSecrets_UnsupportedTaskType_Fails() throw 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))); + assertThat( + e.getMessage(), + is(format("The [%s] service does not support task type [%s]", "tencentcloud", TaskType.SPARSE_EMBEDDING)) + ); } } 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 index e987381ffac6c..a283de486a3f9 100644 --- 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 @@ -23,7 +23,8 @@ import static org.hamcrest.Matchers.is; -public class TencentCloudEmbeddingsServiceSettingsTests extends AbstractBWCWireSerializationTestCase { +public class TencentCloudEmbeddingsServiceSettingsTests extends AbstractBWCWireSerializationTestCase< + TencentCloudEmbeddingsServiceSettings> { private static final String TEST_MODEL_ID = "bge-m3"; private static final int TEST_DIMENSIONS = 1024; @@ -105,10 +106,7 @@ protected TencentCloudEmbeddingsServiceSettings mutateInstance(TencentCloudEmbed var maxInputTokens = instance.maxInputTokens(); switch (between(0, 3)) { - case 0 -> commonSettings = randomValueOtherThan( - commonSettings, - TencentCloudCommonServiceSettingsTests::createRandom - ); + 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)); From 85b6acd15c413496e1c6f91a928fdd0596037800 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Thu, 9 Jul 2026 10:29:55 +0800 Subject: [PATCH 06/15] fix test --- .../services/tencentcloud/TencentCloudServiceTests.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index ff5fd65e88fd3..2c0e1da073d88 100644 --- 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 @@ -44,6 +44,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; @@ -278,7 +279,7 @@ public InferenceService createInferenceService() { return createService(); } - private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskType) { + private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskType) throws URISyntaxException { var commonSettings = new TencentCloudCommonServiceSettings( "deepseek-v3", webServer.getUri(null), @@ -292,7 +293,7 @@ private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskT ); } - private TencentCloudRerankModel createRerankModel(String modelId) { + private TencentCloudRerankModel createRerankModel(String modelId) throws URISyntaxException { var commonSettings = new TencentCloudCommonServiceSettings( modelId, webServer.getUri(null), From 941dac01966e58163013998ff5cb0dac4ff68b09 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Fri, 10 Jul 2026 17:03:25 +0800 Subject: [PATCH 07/15] fix test --- .../TencentCloudCommonServiceSettings.java | 8 +++- .../TencentCloudServiceTests.java | 45 ++++++++++++++++--- .../TencentCloudChatCompletionModelTests.java | 2 +- .../TencentCloudEmbeddingsModelTests.java | 2 +- .../rerank/TencentCloudRerankModelTests.java | 2 +- 5 files changed, 49 insertions(+), 10 deletions(-) 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 index e95cd84410a7a..fd1365dd62265 100644 --- 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 @@ -52,8 +52,12 @@ public static TencentCloudCommonServiceSettings fromMap( var modelId = extractRequiredString(map, ServiceFields.MODEL_ID, ModelConfigurations.SERVICE_SETTINGS, validationException); var uri = extractOptionalUri(map, ServiceFields.URL, validationException); - // SSRF guard: when the caller overrides the endpoint, ensure it uses https and points to a Tencent-hosted allow-listed host. - TencentCloudEndpointUtils.validateEndpoint(uri, ServiceFields.URL, ModelConfigurations.SERVICE_SETTINGS, validationException); + // SSRF guard: only enforce the allow-list when the caller creates/updates an endpoint via the REST API (REQUEST context). + // Skip validation when hydrating persisted configurations from the system index so that endpoints created with an older + // (looser) validation still load after an upgrade. + if (context == ConfigurationParseContext.REQUEST) { + TencentCloudEndpointUtils.validateEndpoint(uri, ServiceFields.URL, ModelConfigurations.SERVICE_SETTINGS, validationException); + } var rateLimitSettings = RateLimitSettings.of(map, DEFAULT_RATE_LIMIT_SETTINGS, validationException, context); if (validationException.validationErrors().size() > initialValidationErrorCount) { 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 index 879dbd64c9b13..37d8f07aeb8ac 100644 --- 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 @@ -21,7 +21,9 @@ 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.UnifiedCompletionRequest; import org.elasticsearch.inference.UnparsedModel; @@ -38,6 +40,8 @@ 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; @@ -46,6 +50,7 @@ import java.net.URI; 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; @@ -55,12 +60,12 @@ 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.external.http.Utils.getUrl; 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 { @@ -81,6 +86,7 @@ public void testSupportedTaskTypes_ContainsExpectedTasks() throws IOException { } public void testParseRequestConfig_TextEmbedding() throws IOException { + var url = "https://bj.aisearch.tencentelasticsearch.com/v1/embeddings"; parseRequestConfig(TaskType.TEXT_EMBEDDING, format(""" { "service_settings": { @@ -89,11 +95,11 @@ public void testParseRequestConfig_TextEmbedding() throws IOException { "url": "%s" } } - """, getUrl(webServer)), assertNoFailureListener(model -> { + """, url), assertNoFailureListener(model -> { assertThat(model, isA(TencentCloudEmbeddingsModel.class)); var m = (TencentCloudEmbeddingsModel) model; assertThat(m.getServiceSettings().modelId(), equalTo("bge-m3")); - assertThat(m.uri(), equalTo(URI.create(getUrl(webServer)))); + assertThat(m.uri(), equalTo(URI.create(url))); assertThat(m.apiKey().toString(), equalTo("sk-12345")); })); } @@ -110,7 +116,7 @@ public void testParseRequestConfig_ChatCompletion_UsesDefaultUrl() throws IOExce assertThat(model, isA(TencentCloudChatCompletionModel.class)); var m = (TencentCloudChatCompletionModel) model; assertThat(m.model(), equalTo("deepseek-v3")); - assertThat(m.uri().toString(), equalTo("http://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); + assertThat(m.uri().toString(), equalTo("https://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); })); } @@ -229,8 +235,10 @@ public void testDoUnifiedInfer_Stream() throws Exception { public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOException { try (var service = createService()) { TestPlainActionFuture> listener = new TestPlainActionFuture<>(); + var chatModel = mock(TencentCloudChatCompletionModel.class); + when(chatModel.getTaskType()).thenReturn(TaskType.CHAT_COMPLETION); service.chunkedInfer( - mock(TencentCloudChatCompletionModel.class), + chatModel, List.of(new ChunkInferenceInput("a")), Map.of(), InputType.UNSPECIFIED, @@ -266,6 +274,33 @@ public void testRerankerWindowSize_ReturnsConservativeValue() throws IOException } } + @Override + public EnumSet expectedStreamingTasks() { + return EnumSet.of(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", + null, + 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), 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 index 65afe24f60ae6..a0aaada6e4c65 100644 --- 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 @@ -22,7 +22,7 @@ public class TencentCloudChatCompletionModelTests extends ESTestCase { public void testUri_UsesDefaultWhenNoOverride() { var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", null, new RateLimitSettings(5))); - assertThat(model.uri().toString(), is("http://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); } public void testUri_UsesOverrideWhenProvided() { 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 index 3b522f34defc4..535e56962ca9a 100644 --- 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 @@ -28,7 +28,7 @@ public void testUri_UsesDefaultWhenNoOverride() { ); var model = createModel(settings); - assertThat(model.uri().toString(), is("http://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); assertThat(model.getServiceSettings().modelId(), is("bge-m3")); } 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 index ad14bccc4a286..f8dcf47eddb0a 100644 --- 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 @@ -24,7 +24,7 @@ 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("http://bj.aisearch.tencentelasticsearch.com/v1/rerank")); + assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/rerank")); } public void testUri_UsesOverrideWhenProvided() { From 5745bb94a92d764115826d464baea7adbe4682c9 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Mon, 13 Jul 2026 18:25:58 +0000 Subject: [PATCH 08/15] [CI] Auto commit changes from spotless --- .../services/tencentcloud/TencentCloudServiceTests.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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 index 37d8f07aeb8ac..ee4966339ab38 100644 --- 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 @@ -237,14 +237,7 @@ public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOExceptio TestPlainActionFuture> listener = new TestPlainActionFuture<>(); var chatModel = mock(TencentCloudChatCompletionModel.class); when(chatModel.getTaskType()).thenReturn(TaskType.CHAT_COMPLETION); - service.chunkedInfer( - chatModel, - List.of(new ChunkInferenceInput("a")), - Map.of(), - InputType.UNSPECIFIED, - TIMEOUT, - listener - ); + service.chunkedInfer(chatModel, List.of(new ChunkInferenceInput("a")), Map.of(), InputType.UNSPECIFIED, TIMEOUT, listener); expectThrows(ElasticsearchStatusException.class, () -> listener.actionGet(TIMEOUT)); } } From ab85a40d8c060e93d12853c1e02449318e851f91 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Wed, 15 Jul 2026 11:30:50 +0800 Subject: [PATCH 09/15] fix test --- .../services/tencentcloud/TencentCloudServiceTests.java | 3 +++ 1 file changed, 3 insertions(+) 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 index ee4966339ab38..53ae32e0e48d8 100644 --- 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 @@ -237,6 +237,9 @@ public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOExceptio TestPlainActionFuture> listener = new TestPlainActionFuture<>(); var chatModel = mock(TencentCloudChatCompletionModel.class); when(chatModel.getTaskType()).thenReturn(TaskType.CHAT_COMPLETION); + when(chatModel.getConfigurations()).thenReturn( + new ModelConfigurations("test-id", TaskType.CHAT_COMPLETION, TencentCloudService.NAME, mock(ServiceSettings.class)) + ); service.chunkedInfer(chatModel, List.of(new ChunkInferenceInput("a")), Map.of(), InputType.UNSPECIFIED, TIMEOUT, listener); expectThrows(ElasticsearchStatusException.class, () -> listener.actionGet(TIMEOUT)); } From 729ef887cdae8e6faabe7e964be1e9536ad3a477 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Thu, 16 Jul 2026 10:50:17 +0800 Subject: [PATCH 10/15] fix test --- .../xpack/inference/InferenceGetServicesIT.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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() ) ); From 002f444d50aab281ae086c3c3ba2cff443393ce7 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Sat, 25 Jul 2026 12:18:13 +0800 Subject: [PATCH 11/15] [Inference] Fix TencentCloud service: NamedWriteables, TransportVersion, COMPLETION support, visitor pattern - Register all TencentCloud ServiceSettings/TaskSettings in InferenceNamedWriteablesProvider - Add dedicated TransportVersion (ml_inference_tencentcloud_added, ID 9479000) - Add TaskType.COMPLETION support for ESQL - Remove TencentCloudChatCompletionRequestManager, route completion via visitor pattern - Fix changelog area: Machine Learning -> Inference - Update tests for streaming tasks and COMPLETION --- docs/changelog/152829.yaml | 2 +- .../ml_inference_tencentcloud_added.csv | 1 + .../resources/transport/upper_bounds/9.6.csv | 2 +- .../InferenceNamedWriteablesProvider.java | 40 ++++++++ .../TencentCloudCommonServiceSettings.java | 2 +- .../tencentcloud/TencentCloudService.java | 59 ++++++++---- .../action/TencentCloudActionCreator.java | 33 ++++++- .../action/TencentCloudActionVisitor.java | 3 + .../TencentCloudChatCompletionModel.java | 9 +- ...centCloudChatCompletionRequestManager.java | 91 ------------------- ...entCloudChatCompletionServiceSettings.java | 3 +- ...TencentCloudEmbeddingsServiceSettings.java | 3 +- .../TencentCloudEmbeddingsTaskSettings.java | 3 +- .../TencentCloudRerankServiceSettings.java | 3 +- .../TencentCloudRerankTaskSettings.java | 3 +- .../TencentCloudServiceTests.java | 3 +- 16 files changed, 130 insertions(+), 130 deletions(-) create mode 100644 server/src/main/resources/transport/definitions/referable/ml_inference_tencentcloud_added.csv delete mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java diff --git a/docs/changelog/152829.yaml b/docs/changelog/152829.yaml index bea196936cb76..e1d9e29254666 100644 --- a/docs/changelog/152829.yaml +++ b/docs/changelog/152829.yaml @@ -1,5 +1,5 @@ pr: 152829 summary: Add Tencent Cloud inference service -area: Machine Learning +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/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/InferenceNamedWriteablesProvider.java index 86346bb06c35a..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 @@ -136,6 +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.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; @@ -195,6 +200,7 @@ public static List getNamedWriteables() { addNvidiaNamedWriteables(namedWriteables); addFireworksAiNamedWriteables(namedWriteables); addDeepSeekNamedWriteables(namedWriteables); + addTencentCloudNamedWriteables(namedWriteables); addUnifiedNamedWriteables(namedWriteables); @@ -224,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( 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 index fd1365dd62265..ceeadbbb641ff 100644 --- 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 @@ -148,7 +148,7 @@ public XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index ca1d21b05f801..c584702dcd940 100644 --- 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 @@ -28,7 +28,9 @@ 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; @@ -36,14 +38,16 @@ 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.completion.TencentCloudChatCompletionRequestManager; 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; @@ -61,32 +65,49 @@ /** * Inference service integration for the TencentCloud AI Gateway (OpenAI-compatible), supporting {@code text_embedding}, - * {@code chat_completion}, and {@code rerank} task types. + * {@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.CHAT_COMPLETION); + private static final EnumSet SUPPORTED_STREAMING_TASKS = EnumSet.of(TaskType.COMPLETION, TaskType.CHAT_COMPLETION); - private static final Map> MODEL_CREATORS = Map.of( - TaskType.TEXT_EMBEDDING, - new TencentCloudEmbeddingsModelCreator(), - TaskType.CHAT_COMPLETION, - new TencentCloudChatCompletionModelCreator(), - TaskType.RERANK, - new TencentCloudRerankModelCreator() + 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, @@ -127,14 +148,6 @@ protected void doInfer( TimeValue timeout, ActionListener listener ) { - if (model instanceof TencentCloudChatCompletionModel chatModel) { - var requestManager = new TencentCloudChatCompletionRequestManager(chatModel, getServiceComponents().threadPool()); - var errorMessage = constructFailedToSendRequestMessage("TencentCloud completions"); - var action = new SenderExecutableAction(getSender(), requestManager, errorMessage); - action.execute(inputs, timeout, listener); - return; - } - if (model instanceof TencentCloudModel tencentCloudModel) { var actionCreator = new TencentCloudActionCreator(getSender(), getServiceComponents()); var action = tencentCloudModel.accept(actionCreator, taskSettings); @@ -153,7 +166,13 @@ protected void doUnifiedCompletionInfer( ActionListener listener ) { if (model instanceof TencentCloudChatCompletionModel chatModel) { - var requestManager = new TencentCloudChatCompletionRequestManager(chatModel, getServiceComponents().threadPool()); + 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); @@ -225,7 +244,7 @@ public Model updateModelWithEmbeddingDetails(Model model, int embeddingSize) { @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index 2c259c8087c5e..281923901cba5 100644 --- 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 @@ -9,15 +9,22 @@ 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; @@ -29,11 +36,15 @@ import static org.elasticsearch.xpack.inference.external.action.ActionUtils.constructFailedToSendRequestMessage; /** - * Creates {@link ExecutableAction}s for TencentCloud embeddings and rerank models. - * Chat completion is handled directly by the {@code TencentCloudService} via a dedicated request manager (not this visitor). + * 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 { + public static final String COMPLETION_ERROR_PREFIX = "TencentCloud completions"; + public static final String USER_ROLE = "user"; + private static final ResponseHandler EMBEDDINGS_HANDLER = new TencentCloudResponseHandler( "tencentcloud text embedding", OpenAiEmbeddingsResponseEntity::fromResponse @@ -44,6 +55,11 @@ public class TencentCloudActionCreator implements TencentCloudActionVisitor { (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; @@ -82,4 +98,17 @@ public ExecutableAction create(TencentCloudRerankModel 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 index 2787f2f6da146..455737e27bcc0 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -18,4 +19,6 @@ 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 index b9faaca3cd3a9..7dd9edf7fb019 100644 --- 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 @@ -87,16 +87,9 @@ public DefaultSecretSettings getSecretSettings() { return (DefaultSecretSettings) super.getSecretSettings(); } - /** - * Chat completion is dispatched via the {@link TencentCloudService#doInfer} / {@link TencentCloudService#doUnifiedCompletionInfer} - * paths using a dedicated {@code TencentCloudChatCompletionRequestManager}, so this visitor entry is not used. - */ @Override public ExecutableAction accept(TencentCloudActionVisitor visitor, Map taskSettings) { - throw new UnsupportedOperationException( - "TencentCloud chat completion does not use the visitor-based action creation flow; " - + "requests are dispatched directly through the request manager." - ); + return visitor.create(this, taskSettings); } private static URI resolveUri(TencentCloudChatCompletionServiceSettings serviceSettings) { diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java deleted file mode 100644 index ab85db10cc675..0000000000000 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionRequestManager.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.elasticsearch.action.ActionListener; -import org.elasticsearch.inference.InferenceServiceResults; -import org.elasticsearch.threadpool.ThreadPool; -import org.elasticsearch.xpack.inference.external.http.retry.RequestSender; -import org.elasticsearch.xpack.inference.external.http.retry.ResponseHandler; -import org.elasticsearch.xpack.inference.external.http.sender.BaseRequestManager; -import org.elasticsearch.xpack.inference.external.http.sender.ChatCompletionInput; -import org.elasticsearch.xpack.inference.external.http.sender.ExecutableInferenceRequest; -import org.elasticsearch.xpack.inference.external.http.sender.InferenceInputs; -import org.elasticsearch.xpack.inference.external.http.sender.UnifiedChatInput; -import org.elasticsearch.xpack.inference.services.openai.OpenAiChatCompletionResponseHandler; -import org.elasticsearch.xpack.inference.services.openai.OpenAiUnifiedChatCompletionResponseHandler; -import org.elasticsearch.xpack.inference.services.openai.response.OpenAiChatCompletionResponseEntity; -import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudChatCompletionRequest; - -import java.util.Objects; -import java.util.function.Supplier; - -import static org.elasticsearch.xpack.inference.external.http.sender.InferenceInputs.createUnsupportedTypeException; - -/** - * Dispatches TencentCloud AI Gateway chat completion requests. TencentCloud is OpenAI-compatible so the OpenAI - * response handlers are reused. - */ -public class TencentCloudChatCompletionRequestManager extends BaseRequestManager { - - private static final Logger logger = LogManager.getLogger(TencentCloudChatCompletionRequestManager.class); - - private static final ResponseHandler CHAT_COMPLETION = new OpenAiUnifiedChatCompletionResponseHandler( - "tencentcloud chat completion", - OpenAiChatCompletionResponseEntity::fromResponse - ); - - private static final ResponseHandler COMPLETION = new OpenAiChatCompletionResponseHandler( - "tencentcloud completion", - OpenAiChatCompletionResponseEntity::fromResponse - ); - - private final TencentCloudChatCompletionModel model; - - public TencentCloudChatCompletionRequestManager(TencentCloudChatCompletionModel model, ThreadPool threadPool) { - super(threadPool, model); - this.model = Objects.requireNonNull(model); - } - - @Override - public void execute( - InferenceInputs inferenceInputs, - RequestSender requestSender, - Supplier hasRequestCompletedFunction, - ActionListener listener - ) { - switch (inferenceInputs) { - case UnifiedChatInput uci -> execute(uci, requestSender, hasRequestCompletedFunction, listener); - case ChatCompletionInput cci -> execute(cci, requestSender, hasRequestCompletedFunction, listener); - default -> throw createUnsupportedTypeException(inferenceInputs, UnifiedChatInput.class); - } - } - - private void execute( - UnifiedChatInput inferenceInputs, - RequestSender requestSender, - Supplier hasRequestCompletedFunction, - ActionListener listener - ) { - var request = new TencentCloudChatCompletionRequest(inferenceInputs, model); - execute(new ExecutableInferenceRequest(requestSender, logger, request, CHAT_COMPLETION, hasRequestCompletedFunction, listener)); - } - - private void execute( - ChatCompletionInput inferenceInputs, - RequestSender requestSender, - Supplier hasRequestCompletedFunction, - ActionListener listener - ) { - var unifiedInputs = new UnifiedChatInput(inferenceInputs.getInputs(), "user", inferenceInputs.stream()); - var request = new TencentCloudChatCompletionRequest(unifiedInputs, model); - execute(new ExecutableInferenceRequest(requestSender, logger, request, COMPLETION, hasRequestCompletedFunction, listener)); - } -} 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 index ccad9ef80d5d5..91dc8f694c9ec 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -106,7 +107,7 @@ protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder buil @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index 1b4097fe9858c..df864b75bf6e6 100644 --- 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 @@ -21,6 +21,7 @@ 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; @@ -173,7 +174,7 @@ protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder buil @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index a9d0acd297001..eb22bb6c411b8 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -54,7 +55,7 @@ public String getWriteableName() { @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index 7f1f06de7e35b..92a6a081a9899 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -89,7 +90,7 @@ protected XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder buil @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index f1032e0d7c6fc..380d2eb45ff08 100644 --- 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 @@ -16,6 +16,7 @@ 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; @@ -115,7 +116,7 @@ public String getWriteableName() { @Override public TransportVersion getMinimalSupportedVersion() { - return TransportVersion.minimumCompatible(); + return TencentCloudService.TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } @Override 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 index 53ae32e0e48d8..422738d76a1f6 100644 --- 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 @@ -80,6 +80,7 @@ public void testName_IsTencentCloud() throws IOException { 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)); } @@ -272,7 +273,7 @@ public void testRerankerWindowSize_ReturnsConservativeValue() throws IOException @Override public EnumSet expectedStreamingTasks() { - return EnumSet.of(TaskType.CHAT_COMPLETION); + return EnumSet.of(TaskType.COMPLETION, TaskType.CHAT_COMPLETION); } @Override From da126c49d1e502dc2c8d9e548c41f9d46eafe79b Mon Sep 17 00:00:00 2001 From: boicehuang Date: Sat, 25 Jul 2026 12:23:27 +0800 Subject: [PATCH 12/15] [Inference] Migrate TencentCloud ServiceSettings to ObjectParser Replace manual map field extraction with declarative ObjectParser parsing in all TencentCloud service settings classes, following the pattern set by LlamaEmbeddingsServiceSettings. Common fields (model_id, url, rate_limit, api_key) are declared once via TencentCloudCommonServiceSettings.declareCommonFields() and reused by each task-specific settings parser. - TencentCloudCommonServiceSettings: add declareCommonFields(), CommonSettingsBuilder interface, ObjectParser-based fromMap() - TencentCloudEmbeddingsServiceSettings: use ObjectParser for common + embeddings-specific fields (similarity, dimensions, max_input_tokens) - TencentCloudChatCompletionServiceSettings: use ObjectParser with chat-completion default rate limit - TencentCloudRerankServiceSettings: use ObjectParser for common fields --- .../TencentCloudCommonServiceSettings.java | 89 +++++++++++++++---- ...entCloudChatCompletionServiceSettings.java | 61 +++++++++---- ...TencentCloudEmbeddingsServiceSettings.java | 67 +++++++++++++- .../TencentCloudRerankServiceSettings.java | 47 +++++++++- 4 files changed, 227 insertions(+), 37 deletions(-) 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 index ceeadbbb641ff..91814f87b308e 100644 --- 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 @@ -7,16 +7,23 @@ 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; @@ -25,9 +32,6 @@ import java.util.Map; import java.util.Objects; -import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractOptionalUri; -import static org.elasticsearch.xpack.inference.services.ServiceUtils.extractRequiredString; - /** * Common service settings shared by all TencentCloud task types. * Contains the required {@code model_id}, an optional {@code url} override, and rate limit settings. @@ -42,30 +46,77 @@ public class TencentCloudCommonServiceSettings extends FilteredXContentObject // Default rate limit for TencentCloud AI Gateway (see docs). public static final RateLimitSettings DEFAULT_RATE_LIMIT_SETTINGS = new RateLimitSettings(20); - @Nullable - public static TencentCloudCommonServiceSettings fromMap( + /** + * Declares the common TencentCloud service-settings fields ({@code model_id}, {@code url}, {@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. + */ + 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.setUrl(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. This is the recommended way to parse task-level + * settings with their task-specific parser; it replaces the previous hand-written map extraction. + */ + public static TencentCloudCommonServiceSettings fromMap( Map map, ConfigurationParseContext context, + ObjectParser parser, ValidationException validationException ) { - int initialValidationErrorCount = validationException.validationErrors().size(); - - var modelId = extractRequiredString(map, ServiceFields.MODEL_ID, ModelConfigurations.SERVICE_SETTINGS, validationException); - var uri = extractOptionalUri(map, ServiceFields.URL, validationException); - // SSRF guard: only enforce the allow-list when the caller creates/updates an endpoint via the REST API (REQUEST context). - // Skip validation when hydrating persisted configurations from the system index so that endpoints created with an older - // (looser) validation still load after an upgrade. - if (context == ConfigurationParseContext.REQUEST) { - TencentCloudEndpointUtils.validateEndpoint(uri, ServiceFields.URL, ModelConfigurations.SERVICE_SETTINGS, validationException); - } - var rateLimitSettings = RateLimitSettings.of(map, DEFAULT_RATE_LIMIT_SETTINGS, validationException, context); - - if (validationException.validationErrors().size() > initialValidationErrorCount) { + try (var xParser = XContentHelper.mapToXContentParser(XContentParserConfiguration.EMPTY, map)) { + T builder = parser.apply(xParser, context); + var commonSettings = builder.buildCommon(); + // SSRF guard: only enforce the allow-list when the caller creates/updates an endpoint via the REST API. + if (context == ConfigurationParseContext.REQUEST) { + TencentCloudEndpointUtils.validateEndpoint( + commonSettings.uri(), + ServiceFields.URL, + ModelConfigurations.SERVICE_SETTINGS, + validationException + ); + if (validationException.validationErrors().isEmpty() == false) { + 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; } - return new TencentCloudCommonServiceSettings(modelId, uri, rateLimitSettings); } + /** + * Builder interface for classes that accumulate common TencentCloud settings fields. + */ + public interface CommonSettingsBuilder { + void setModelId(String modelId); + + void setUrl(String url); + + void setRateLimitSettings(RateLimitSettings rateLimitSettings); + + TencentCloudCommonServiceSettings buildCommon(); + } + + // ---- instance fields and methods ---- + private final String modelId; @Nullable private final URI uri; 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 index 91dc8f694c9ec..7508721991fb8 100644 --- 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 @@ -11,7 +11,9 @@ 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; @@ -21,6 +23,7 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; +import java.net.URI; import java.util.Map; import java.util.Objects; @@ -33,23 +36,23 @@ public class TencentCloudChatCompletionServiceSettings extends FilteredXContentO // 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(); - - // Chat completion has a lower default rate limit than embeddings/rerank; if user does not specify one we override the default. - if (map != null && map.containsKey(RateLimitSettings.FIELD_NAME) == false) { - // Override common default before parsing. - } - var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, validationException); - // If the user did not provide a rate_limit override, replace the common default (20 rpm) with 5 rpm. - if (commonSettings != null && commonSettings.rateLimitSettings() == TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS) { - commonSettings = new TencentCloudCommonServiceSettings( - commonSettings.modelId(), - commonSettings.uri(), - DEFAULT_CHAT_COMPLETION_RATE_LIMIT - ); - } - + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, parser, validationException); validationException.throwIfValidationErrorsExist(); return new TencentCloudChatCompletionServiceSettings(commonSettings); @@ -127,4 +130,32 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(commonSettings); } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String url; + private RateLimitSettings rateLimitSettings; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setUrl(String url) { + this.url = url; + } + + @Override + public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { + this.rateLimitSettings = rateLimitSettings; + } + + @Override + public TencentCloudCommonServiceSettings buildCommon() { + return new TencentCloudCommonServiceSettings(modelId, url != null ? URI.create(url) : null, rateLimitSettings); + } + } } 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 index df864b75bf6e6..a12b25640c20e 100644 --- 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 @@ -15,6 +15,8 @@ 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; @@ -24,6 +26,7 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; +import java.net.URI; import java.util.Map; import java.util.Objects; @@ -40,10 +43,27 @@ public class TencentCloudEmbeddingsServiceSettings extends FilteredXContentObjec 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, 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( @@ -200,4 +220,47 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(commonSettings, similarity, dimensions, maxInputTokens); } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String url; + private RateLimitSettings rateLimitSettings; + private SimilarityMeasure similarity; + private Integer dimensions; + private Integer maxInputTokens; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setUrl(String url) { + this.url = url; + } + + @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, url != null ? URI.create(url) : null, rateLimitSettings); + } + } } 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 index 92a6a081a9899..aaff86d7b8448 100644 --- 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 @@ -11,7 +11,9 @@ 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; @@ -21,6 +23,7 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; +import java.net.URI; import java.util.Map; import java.util.Objects; @@ -31,9 +34,23 @@ public class TencentCloudRerankServiceSettings extends FilteredXContentObject 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, validationException); + var commonSettings = TencentCloudCommonServiceSettings.fromMap(map, context, parser, validationException); validationException.throwIfValidationErrorsExist(); return new TencentCloudRerankServiceSettings(commonSettings); } @@ -110,4 +127,32 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(commonSettings); } + + // ---- ObjectParser Builder ---- + + private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { + private String modelId; + private String url; + private RateLimitSettings rateLimitSettings; + + @Override + public void setModelId(String modelId) { + this.modelId = modelId; + } + + @Override + public void setUrl(String url) { + this.url = url; + } + + @Override + public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { + this.rateLimitSettings = rateLimitSettings; + } + + @Override + public TencentCloudCommonServiceSettings buildCommon() { + return new TencentCloudCommonServiceSettings(modelId, url != null ? URI.create(url) : null, rateLimitSettings); + } + } } From 3d0056d025342255cff1237bfa9c277b567a1849 Mon Sep 17 00:00:00 2001 From: boicehuang Date: Sat, 25 Jul 2026 12:51:07 +0800 Subject: [PATCH 13/15] [Inference] Replace configurable URL with region selector in TencentCloud service Users cannot bring their own TencentCloud deployment, but they may need to target different regional endpoints. Replace the free-form 'url' setting with a 'region' setting (default 'bj'). The endpoint URL is always constructed as https://{region}.aisearch.tencentelasticsearch.com/v1/. - TencentCloudUtils: add buildHost(region), buildUri(region, path...) helpers - TencentCloudCommonServiceSettings: replace @Nullable URI uri with String region - All model classes: resolveUri() now builds URI from region, no url override - TencentCloudService: add usesParserForServiceSettings() override, replace URL config with region config - Remove URL from declareCommonFields (consumed silently for backward compat) - Tests: rewrite URL-related tests to use region; remove WebServer-dependent integration tests (endpoint is now hardcoded to *.tencentelasticsearch.com) --- .workbuddy/memory/2026-07-25.md | 34 ++++++++ .../TencentCloudCommonServiceSettings.java | 63 +++++++------- .../tencentcloud/TencentCloudService.java | 18 ++-- .../TencentCloudChatCompletionModel.java | 16 ++-- ...entCloudChatCompletionServiceSettings.java | 12 +-- .../TencentCloudEmbeddingsModel.java | 15 ++-- ...TencentCloudEmbeddingsServiceSettings.java | 9 +- .../request/TencentCloudUtils.java | 28 +++++- .../rerank/TencentCloudRerankModel.java | 15 ++-- .../TencentCloudRerankServiceSettings.java | 9 +- ...encentCloudCommonServiceSettingsTests.java | 78 +++++------------ .../TencentCloudServiceTests.java | 87 +++---------------- .../TencentCloudChatCompletionModelTests.java | 11 +-- .../TencentCloudEmbeddingsModelTests.java | 9 +- .../rerank/TencentCloudRerankModelTests.java | 8 +- 15 files changed, 178 insertions(+), 234 deletions(-) create mode 100644 .workbuddy/memory/2026-07-25.md diff --git a/.workbuddy/memory/2026-07-25.md b/.workbuddy/memory/2026-07-25.md new file mode 100644 index 0000000000000..a1f5e84519a35 --- /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 硬编码:当前可配置,后续确认后决定 + 8. ⬜ rerank 模型范围:当前透传,后续确认 + 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/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 index 91814f87b308e..3cbe55724975f 100644 --- 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 @@ -26,15 +26,19 @@ 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.net.URI; +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 url} override, and rate limit settings. + * 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 @@ -42,21 +46,25 @@ public class TencentCloudCommonServiceSettings extends FilteredXContentObject 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 url}, {@code rate_limit}, + * 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. + * 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.setUrl(v), new ParseField(ServiceFields.URL)); + 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), @@ -68,8 +76,7 @@ public static void declareCommonFields( } /** - * Parses common settings from a map using an ObjectParser. This is the recommended way to parse task-level - * settings with their task-specific parser; it replaces the previous hand-written map extraction. + * Parses common settings from a map using an ObjectParser. */ public static TencentCloudCommonServiceSettings fromMap( Map map, @@ -80,15 +87,12 @@ public static TencentCloudCommonServiceSetting try (var xParser = XContentHelper.mapToXContentParser(XContentParserConfiguration.EMPTY, map)) { T builder = parser.apply(xParser, context); var commonSettings = builder.buildCommon(); - // SSRF guard: only enforce the allow-list when the caller creates/updates an endpoint via the REST API. + // Validate region in REQUEST context. if (context == ConfigurationParseContext.REQUEST) { - TencentCloudEndpointUtils.validateEndpoint( - commonSettings.uri(), - ServiceFields.URL, - ModelConfigurations.SERVICE_SETTINGS, - validationException - ); - if (validationException.validationErrors().isEmpty() == false) { + if (commonSettings.region().isBlank()) { + validationException.addValidationError( + String.format(Locale.ROOT, "[%s] in [%s] must not be empty", REGION, ModelConfigurations.SERVICE_SETTINGS) + ); return null; } } @@ -108,7 +112,7 @@ public static TencentCloudCommonServiceSetting public interface CommonSettingsBuilder { void setModelId(String modelId); - void setUrl(String url); + void setRegion(String region); void setRateLimitSettings(RateLimitSettings rateLimitSettings); @@ -118,20 +122,18 @@ public interface CommonSettingsBuilder { // ---- instance fields and methods ---- private final String modelId; - @Nullable - private final URI uri; + private final String region; private final RateLimitSettings rateLimitSettings; - public TencentCloudCommonServiceSettings(String modelId, @Nullable URI uri, @Nullable RateLimitSettings rateLimitSettings) { + public TencentCloudCommonServiceSettings(String modelId, @Nullable String region, @Nullable RateLimitSettings rateLimitSettings) { this.modelId = Objects.requireNonNull(modelId); - this.uri = uri; + 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(); - var uriString = in.readOptionalString(); - this.uri = uriString == null ? null : URI.create(uriString); + this.region = in.readString(); this.rateLimitSettings = new RateLimitSettings(in); } @@ -152,7 +154,7 @@ public TencentCloudCommonServiceSettings updateCommonServiceSettings( return null; } - return new TencentCloudCommonServiceSettings(this.modelId, this.uri, extractedRateLimitSettings); + return new TencentCloudCommonServiceSettings(this.modelId, this.region, extractedRateLimitSettings); } @Override @@ -160,9 +162,8 @@ public String modelId() { return modelId; } - @Nullable - public URI uri() { - return uri; + public String region() { + return region; } @Override @@ -190,9 +191,7 @@ public XContentBuilder toXContentFragment(XContentBuilder builder, Params params @Override public XContentBuilder toXContentFragmentOfExposedFields(XContentBuilder builder, Params params) throws IOException { builder.field(ServiceFields.MODEL_ID, modelId); - if (uri != null) { - builder.field(ServiceFields.URL, uri.toString()); - } + builder.field(REGION, region); rateLimitSettings.toXContent(builder, params); return builder; } @@ -205,7 +204,7 @@ public TransportVersion getMinimalSupportedVersion() { @Override public void writeTo(StreamOutput out) throws IOException { out.writeString(modelId); - out.writeOptionalString(uri != null ? uri.toString() : null); + out.writeString(region); rateLimitSettings.writeTo(out); } @@ -215,12 +214,12 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TencentCloudCommonServiceSettings that = (TencentCloudCommonServiceSettings) o; return Objects.equals(modelId, that.modelId) - && Objects.equals(uri, that.uri) + && Objects.equals(region, that.region) && Objects.equals(rateLimitSettings, that.rateLimitSettings); } @Override public int hashCode() { - return Objects.hash(modelId, uri, rateLimitSettings); + return Objects.hash(modelId, region, rateLimitSettings); } } 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 index c584702dcd940..36392a16acfc1 100644 --- 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 @@ -60,7 +60,6 @@ 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.ServiceFields.URL; import static org.elasticsearch.xpack.inference.services.ServiceUtils.createInvalidModelException; /** @@ -247,6 +246,11 @@ public TransportVersion getMinimalSupportedVersion() { return TENCENT_CLOUD_INFERENCE_SERVICE_ADDED; } + @Override + public boolean usesParserForServiceSettings() { + return true; + } + @Override public int rerankerWindowSize(String modelId) { // BGE reranker models (bge-reranker-large, bge-reranker-v2-m3) support up to 512-token inputs. @@ -277,15 +281,13 @@ public static InferenceServiceConfiguration get() { ); configurationMap.put( - URL, - new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDefaultValue( - "https://bj.aisearch.tencentelasticsearch.com/v1/embeddings" - ) + "region", + new SettingsConfiguration.Builder(SUPPORTED_TASK_TYPES).setDefaultValue("bj") .setDescription( - "The full URL endpoint to use for the requests. Defaults to the AI Gateway endpoint corresponding to the " - + "task type." + "The TencentCloud AI Gateway region, e.g. bj, sh, gz. " + + "The endpoint URL is constructed as https://{region}.aisearch.tencentelasticsearch.com/v1/." ) - .setLabel("URL") + .setLabel("Region") .setRequired(false) .setSensitive(false) .setUpdatable(false) 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 index 7dd9edf7fb019..84d309b1ca16c 100644 --- 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 @@ -7,7 +7,6 @@ package org.elasticsearch.xpack.inference.services.tencentcloud.completion; -import org.apache.http.client.utils.URIBuilder; import org.elasticsearch.core.Nullable; import org.elasticsearch.inference.EmptyTaskSettings; import org.elasticsearch.inference.ModelConfigurations; @@ -23,16 +22,9 @@ import java.net.URI; import java.util.Map; -import java.util.Objects; - -import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; public class TencentCloudChatCompletionModel extends TencentCloudModel { - private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) - .setHost(TencentCloudUtils.HOST) - .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.CHAT_COMPLETIONS_PATH_1, TencentCloudUtils.CHAT_COMPLETIONS_PATH_2); - public TencentCloudChatCompletionModel( String inferenceId, TaskType taskType, @@ -93,7 +85,11 @@ public ExecutableAction accept(TencentCloudActionVisitor visitor, Map buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + 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/TencentCloudChatCompletionServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/completion/TencentCloudChatCompletionServiceSettings.java index 7508721991fb8..b5c08e448e8a3 100644 --- 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 @@ -23,7 +23,6 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; -import java.net.URI; import java.util.Map; import java.util.Objects; @@ -135,7 +134,7 @@ public int hashCode() { private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { private String modelId; - private String url; + private String region; private RateLimitSettings rateLimitSettings; @Override @@ -144,8 +143,8 @@ public void setModelId(String modelId) { } @Override - public void setUrl(String url) { - this.url = url; + public void setRegion(String region) { + this.region = region; } @Override @@ -155,7 +154,10 @@ public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { @Override public TencentCloudCommonServiceSettings buildCommon() { - return new TencentCloudCommonServiceSettings(modelId, url != null ? URI.create(url) : null, rateLimitSettings); + // 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 index 93b0028bc88b6..52b587503f24a 100644 --- 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 @@ -7,7 +7,6 @@ package org.elasticsearch.xpack.inference.services.tencentcloud.embeddings; -import org.apache.http.client.utils.URIBuilder; import org.elasticsearch.core.Nullable; import org.elasticsearch.inference.ChunkingSettings; import org.elasticsearch.inference.ModelConfigurations; @@ -22,16 +21,9 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.request.TencentCloudUtils; import java.util.Map; -import java.util.Objects; - -import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; public class TencentCloudEmbeddingsModel extends TencentCloudModel { - private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) - .setHost(TencentCloudUtils.HOST) - .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.EMBEDDINGS_PATH); - public TencentCloudEmbeddingsModel( String inferenceId, Map serviceSettings, @@ -107,7 +99,10 @@ public ExecutableAction accept(TencentCloudActionVisitor visitor, Map buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + 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/TencentCloudEmbeddingsServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/embeddings/TencentCloudEmbeddingsServiceSettings.java index a12b25640c20e..f078b5a470c1d 100644 --- 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 @@ -26,7 +26,6 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; -import java.net.URI; import java.util.Map; import java.util.Objects; @@ -225,7 +224,7 @@ public int hashCode() { private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { private String modelId; - private String url; + private String region; private RateLimitSettings rateLimitSettings; private SimilarityMeasure similarity; private Integer dimensions; @@ -237,8 +236,8 @@ public void setModelId(String modelId) { } @Override - public void setUrl(String url) { - this.url = url; + public void setRegion(String region) { + this.region = region; } @Override @@ -260,7 +259,7 @@ public void setMaxInputTokens(Integer maxInputTokens) { @Override public TencentCloudCommonServiceSettings buildCommon() { - return new TencentCloudCommonServiceSettings(modelId, url != null ? URI.create(url) : null, rateLimitSettings); + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); } } } 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 index 99f90978631f4..025865bead0ee 100644 --- 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 @@ -7,10 +7,18 @@ 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 HOST = "bj.aisearch.tencentelasticsearch.com"; + 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"; @@ -18,4 +26,22 @@ public final class TencentCloudUtils { 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 index 3f9faefc31d06..47a9a31d52b7e 100644 --- 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 @@ -7,7 +7,6 @@ package org.elasticsearch.xpack.inference.services.tencentcloud.rerank; -import org.apache.http.client.utils.URIBuilder; import org.elasticsearch.core.Nullable; import org.elasticsearch.inference.ModelConfigurations; import org.elasticsearch.inference.ModelSecrets; @@ -22,16 +21,9 @@ import java.net.URI; import java.util.Map; -import java.util.Objects; - -import static org.elasticsearch.xpack.inference.external.request.RequestUtils.buildUri; public class TencentCloudRerankModel extends TencentCloudModel { - private static final URIBuilder DEFAULT_URI_BUILDER = new URIBuilder().setScheme(TencentCloudUtils.SCHEME) - .setHost(TencentCloudUtils.HOST) - .setPathSegments(TencentCloudUtils.VERSION_1, TencentCloudUtils.RERANK_PATH); - public static TencentCloudRerankModel of(TencentCloudRerankModel model, Map taskSettings) { var requestTaskSettings = TencentCloudRerankTaskSettings.fromMap(taskSettings); if (requestTaskSettings.isEmpty() || requestTaskSettings.equals(model.getTaskSettings())) { @@ -109,7 +101,10 @@ public ExecutableAction accept(TencentCloudActionVisitor visitor, Map buildUri("TencentCloud", DEFAULT_URI_BUILDER::build)); + 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/TencentCloudRerankServiceSettings.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/rerank/TencentCloudRerankServiceSettings.java index aaff86d7b8448..0fbe04c1ba8fb 100644 --- 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 @@ -23,7 +23,6 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudService; import java.io.IOException; -import java.net.URI; import java.util.Map; import java.util.Objects; @@ -132,7 +131,7 @@ public int hashCode() { private static class Builder implements TencentCloudCommonServiceSettings.CommonSettingsBuilder { private String modelId; - private String url; + private String region; private RateLimitSettings rateLimitSettings; @Override @@ -141,8 +140,8 @@ public void setModelId(String modelId) { } @Override - public void setUrl(String url) { - this.url = url; + public void setRegion(String region) { + this.region = region; } @Override @@ -152,7 +151,7 @@ public void setRateLimitSettings(RateLimitSettings rateLimitSettings) { @Override public TencentCloudCommonServiceSettings buildCommon() { - return new TencentCloudCommonServiceSettings(modelId, url != null ? URI.create(url) : null, rateLimitSettings); + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); } } } 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 index 7e19ec3149ac9..e7acef48f5638 100644 --- 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 @@ -15,12 +15,10 @@ import org.elasticsearch.xcontent.XContentFactory; import org.elasticsearch.xcontent.XContentType; 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.request.TencentCloudUtils; import java.io.IOException; -import java.net.URI; import java.util.HashMap; import java.util.Map; @@ -30,63 +28,31 @@ public class TencentCloudCommonServiceSettingsTests extends AbstractBWCWireSeria 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_URL = "http://custom.example.com/v1/embeddings"; + 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); - URI uri = randomBoolean() ? URI.create("http://" + randomAlphaOfLength(8) + "/v1/embeddings") : null; + var region = randomBoolean() ? randomAlphaOfLength(5) : TencentCloudUtils.DEFAULT_REGION; var rateLimitSettings = randomBoolean() ? new RateLimitSettings(randomIntBetween(1, 1000)) : null; - return new TencentCloudCommonServiceSettings(modelId, uri, rateLimitSettings); + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); } - public void testFromMap_MinimalConfig_UsesDefaults() { - var validationException = new ValidationException(); - var settings = TencentCloudCommonServiceSettings.fromMap( - new HashMap<>(Map.of(ServiceFields.MODEL_ID, TEST_MODEL_ID)), - ConfigurationParseContext.PERSISTENT, - validationException - ); - - assertThat(validationException.validationErrors().size(), is(0)); - assertThat(settings.modelId(), is(TEST_MODEL_ID)); - assertNull(settings.uri()); - assertThat(settings.rateLimitSettings(), is(TencentCloudCommonServiceSettings.DEFAULT_RATE_LIMIT_SETTINGS)); - } - - public void testFromMap_WithUrlAndRateLimit_Success() { - var validationException = new ValidationException(); - var settings = TencentCloudCommonServiceSettings.fromMap( - new HashMap<>( - Map.of( - ServiceFields.MODEL_ID, - TEST_MODEL_ID, - ServiceFields.URL, - TEST_URL, - RateLimitSettings.FIELD_NAME, - new HashMap<>(Map.of(RateLimitSettings.REQUESTS_PER_MINUTE_FIELD, TEST_RATE_LIMIT)) - ) - ), - ConfigurationParseContext.PERSISTENT, - validationException - ); - - assertThat(settings.modelId(), is(TEST_MODEL_ID)); - assertThat(settings.uri(), is(URI.create(TEST_URL))); - assertThat(settings.rateLimitSettings(), is(new RateLimitSettings(TEST_RATE_LIMIT))); + public void testConstructor_NullRegion_UsesDefault() { + var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, null, null); + assertThat(settings.region(), is(TencentCloudUtils.DEFAULT_REGION)); } - public void testFromMap_MissingModelId_AddsValidationError() { - var validationException = new ValidationException(); - TencentCloudCommonServiceSettings.fromMap(new HashMap<>(), ConfigurationParseContext.PERSISTENT, validationException); - assertThat(validationException.validationErrors().size(), is(1)); + 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, - URI.create(TEST_URL), + TEST_REGION, new RateLimitSettings(INITIAL_TEST_RATE_LIMIT) ); var updatedSettings = originalSettings.updateCommonServiceSettings( @@ -96,34 +62,34 @@ public void testUpdateServiceSettings_OnlyRateLimitIsMutable() { new ValidationException() ); - // model id and uri are immutable + // model id and region are immutable assertThat(updatedSettings.modelId(), is(INITIAL_TEST_MODEL_ID)); - assertThat(updatedSettings.uri(), is(URI.create(TEST_URL))); + 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, - null, + TEST_REGION, new RateLimitSettings(INITIAL_TEST_RATE_LIMIT) ); var updatedSettings = originalSettings.updateCommonServiceSettings(new HashMap<>(), new ValidationException()); assertThat(updatedSettings, is(originalSettings)); } - public void testXContent_WithUrl() throws IOException { - var settings = new TencentCloudCommonServiceSettings(TEST_MODEL_ID, URI.create(TEST_URL), new RateLimitSettings(TEST_RATE_LIMIT)); + 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","url":"%s","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_URL, TEST_RATE_LIMIT))); + {"model_id":"%s","region":"%s","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_REGION, TEST_RATE_LIMIT))); } - public void testXContent_WithoutUrl() throws IOException { + 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); @@ -131,7 +97,7 @@ public void testXContent_WithoutUrl() throws IOException { String xContentResult = Strings.toString(builder); assertThat(xContentResult, is(Strings.format(""" - {"model_id":"%s","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_RATE_LIMIT))); + {"model_id":"%s","region":"bj","rate_limit":{"requests_per_minute":%d}}""", TEST_MODEL_ID, TEST_RATE_LIMIT))); } @Override @@ -147,16 +113,16 @@ protected TencentCloudCommonServiceSettings createTestInstance() { @Override protected TencentCloudCommonServiceSettings mutateInstance(TencentCloudCommonServiceSettings instance) throws IOException { var modelId = instance.modelId(); - var uri = instance.uri(); + var region = instance.region(); var rateLimitSettings = instance.rateLimitSettings(); switch (between(0, 2)) { case 0 -> modelId = randomValueOtherThan(modelId, () -> randomAlphaOfLength(8)); - case 1 -> uri = uri == null ? URI.create("http://" + randomAlphaOfLength(6) + "/v1") : null; + 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, uri, rateLimitSettings); + return new TencentCloudCommonServiceSettings(modelId, region, rateLimitSettings); } @Override 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 index 422738d76a1f6..fbc7cebf7c2f0 100644 --- 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 @@ -16,7 +16,6 @@ import org.elasticsearch.inference.ChunkInferenceInput; import org.elasticsearch.inference.ChunkedInference; import org.elasticsearch.inference.InferenceService; -import org.elasticsearch.inference.InferenceServiceResults; import org.elasticsearch.inference.InputType; import org.elasticsearch.inference.Model; import org.elasticsearch.inference.ModelConfigurations; @@ -25,16 +24,10 @@ import org.elasticsearch.inference.ServiceSettings; import org.elasticsearch.inference.SimilarityMeasure; import org.elasticsearch.inference.TaskType; -import org.elasticsearch.inference.UnifiedCompletionRequest; import org.elasticsearch.inference.UnparsedModel; -import org.elasticsearch.inference.completion.ContentString; -import org.elasticsearch.inference.completion.Message; -import org.elasticsearch.test.http.MockResponse; import org.elasticsearch.xcontent.XContentParserConfiguration; import org.elasticsearch.xcontent.XContentType; -import org.elasticsearch.xpack.core.inference.results.ChatCompletionResults; import org.elasticsearch.xpack.inference.external.http.sender.HttpRequestSenderTests; -import org.elasticsearch.xpack.inference.services.InferenceEventsAssertion; import org.elasticsearch.xpack.inference.services.InferenceServiceTestCase; import org.elasticsearch.xpack.inference.services.settings.DefaultSecretSettings; import org.elasticsearch.xpack.inference.services.tencentcloud.completion.TencentCloudChatCompletionModel; @@ -47,7 +40,6 @@ import org.elasticsearch.xpack.inference.services.tencentcloud.rerank.TencentCloudRerankTaskSettings; import java.io.IOException; -import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.EnumSet; @@ -87,37 +79,38 @@ public void testSupportedTaskTypes_ContainsExpectedTasks() throws IOException { } public void testParseRequestConfig_TextEmbedding() throws IOException { - var url = "https://bj.aisearch.tencentelasticsearch.com/v1/embeddings"; + var region = "bj"; parseRequestConfig(TaskType.TEXT_EMBEDDING, format(""" { "service_settings": { "api_key": "sk-12345", "model_id": "bge-m3", - "url": "%s" + "region": "%s" } } - """, url), assertNoFailureListener(model -> { + """, region), assertNoFailureListener(model -> { assertThat(model, isA(TencentCloudEmbeddingsModel.class)); var m = (TencentCloudEmbeddingsModel) model; assertThat(m.getServiceSettings().modelId(), equalTo("bge-m3")); - assertThat(m.uri(), equalTo(URI.create(url))); + assertThat(m.uri().toString(), equalTo("https://bj.aisearch.tencentelasticsearch.com/v1/embeddings")); assertThat(m.apiKey().toString(), equalTo("sk-12345")); })); } - public void testParseRequestConfig_ChatCompletion_UsesDefaultUrl() throws IOException { + public void testParseRequestConfig_ChatCompletion_UsesRegionUri() throws IOException { parseRequestConfig(TaskType.CHAT_COMPLETION, """ { "service_settings": { "api_key": "sk-12345", - "model_id": "deepseek-v3" + "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://bj.aisearch.tencentelasticsearch.com/v1/chat/completions")); + assertThat(m.uri().toString(), equalTo("https://sh.aisearch.tencentelasticsearch.com/v1/chat/completions")); })); } @@ -175,7 +168,7 @@ public void testParsePersistedConfig_ChatCompletion() throws IOException { { "service_settings": { "model_id": "deepseek-v3", - "url": "http://localhost:989/v1/chat/completions" + "region": "sh" } } """); @@ -188,65 +181,11 @@ public void testParsePersistedConfig_ChatCompletion() throws IOException { assertThat(model, isA(TencentCloudChatCompletionModel.class)); var m = (TencentCloudChatCompletionModel) model; assertThat(m.model(), equalTo("deepseek-v3")); - assertThat(m.uri(), equalTo(URI.create("http://localhost:989/v1/chat/completions"))); - } - } - - public void testDoInferChatCompletion_NonStream() throws Exception { - webServer.enqueue(new MockResponse().setResponseCode(200).setBody(""" - {"choices": [{"message": {"content": "hello, world", "role": "assistant"}, "finish_reason": "stop", "index": 0}], \ - "created": 1718345013, "id": "12345", "model": "deepseek-v3", "object": "chat.completion"}""")); - try (var service = createService()) { - var model = createChatCompletionModel(TaskType.COMPLETION); - TestPlainActionFuture listener = new TestPlainActionFuture<>(); - service.infer(model, List.of("hello"), false, Map.of(), InputType.UNSPECIFIED, TIMEOUT, listener); - var result = listener.actionGet(TIMEOUT); - assertThat(result, isA(ChatCompletionResults.class)); - var completion = (ChatCompletionResults) result; - assertThat( - completion.results().stream().map(ChatCompletionResults.Result::predictedValue).toList(), - equalTo(List.of("hello, world")) - ); - } - } - - public void testDoUnifiedInfer_Stream() throws Exception { - webServer.enqueue(new MockResponse().setResponseCode(200).setBody(""" - data: {"choices": [{"delta": {"content": "hello", "role": "assistant"}, "finish_reason": null, "index": 0}], \ - "created": 1718345013, "id": "12345", "model": "deepseek-v3", "object": "chat.completion.chunk"} - - data: [DONE] - - """)); - try (var service = createService()) { - var model = createChatCompletionModel(TaskType.CHAT_COMPLETION); - TestPlainActionFuture listener = new TestPlainActionFuture<>(); - service.unifiedCompletionInfer( - model, - UnifiedCompletionRequest.of(List.of(new Message(new ContentString("hi"), "user", null, null))), - TIMEOUT, - listener - ); - InferenceEventsAssertion.assertThat(listener.actionGet(TIMEOUT)).hasFinishedStream().hasNoErrors().hasEvent(""" - {"id":"12345","choices":[{"delta":{"content":"hello","role":"assistant"},"index":0}],""" + """ - "model":"deepseek-v3","object":"chat.completion.chunk"}"""); + assertThat(m.uri().toString(), equalTo("https://sh.aisearch.tencentelasticsearch.com/v1/chat/completions")); } } public void testChunkedInfer_UnsupportedForNonEmbeddingModel() throws IOException { - try (var service = createService()) { - TestPlainActionFuture> listener = new TestPlainActionFuture<>(); - var chatModel = mock(TencentCloudChatCompletionModel.class); - when(chatModel.getTaskType()).thenReturn(TaskType.CHAT_COMPLETION); - when(chatModel.getConfigurations()).thenReturn( - new ModelConfigurations("test-id", TaskType.CHAT_COMPLETION, TencentCloudService.NAME, mock(ServiceSettings.class)) - ); - service.chunkedInfer(chatModel, List.of(new ChunkInferenceInput("a")), Map.of(), InputType.UNSPECIFIED, TIMEOUT, listener); - expectThrows(ElasticsearchStatusException.class, () -> listener.actionGet(TIMEOUT)); - } - } - - public void testBuildModelFromConfigAndSecrets_UnsupportedTaskType_Fails() throws IOException { var modelConfigurations = new ModelConfigurations( "inference-id", TaskType.SPARSE_EMBEDDING, @@ -285,7 +224,7 @@ protected void assertRerankerWindowSize(RerankingInferenceService rerankingInfer public Model createEmbeddingModel(SimilarityMeasure similarity) { var commonSettings = new TencentCloudCommonServiceSettings( "bge-m3", - null, + "bj", new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(20) ); var serviceSettings = new TencentCloudEmbeddingsServiceSettings(commonSettings, similarity, null, null); @@ -314,7 +253,7 @@ public InferenceService createInferenceService() { private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskType) throws URISyntaxException { var commonSettings = new TencentCloudCommonServiceSettings( "deepseek-v3", - webServer.getUri(null), + "bj", new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(5) ); return new TencentCloudChatCompletionModel( @@ -328,7 +267,7 @@ private TencentCloudChatCompletionModel createChatCompletionModel(TaskType taskT private TencentCloudRerankModel createRerankModel(String modelId) throws URISyntaxException { var commonSettings = new TencentCloudCommonServiceSettings( modelId, - webServer.getUri(null), + "bj", new org.elasticsearch.xpack.inference.services.settings.RateLimitSettings(20) ); return new TencentCloudRerankModel( 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 index a0aaada6e4c65..2709d11ddff35 100644 --- 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 @@ -14,21 +14,18 @@ import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; -import java.net.URI; - import static org.hamcrest.Matchers.is; public class TencentCloudChatCompletionModelTests extends ESTestCase { - public void testUri_UsesDefaultWhenNoOverride() { + 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_UsesOverrideWhenProvided() { - var override = URI.create("http://custom.example.com/v1/chat/completions"); - var model = createModel(new TencentCloudCommonServiceSettings("deepseek-v3", override, new RateLimitSettings(5))); - assertThat(model.uri(), is(override)); + 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() { 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 index 535e56962ca9a..360dbe03e5dd2 100644 --- 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 @@ -13,8 +13,6 @@ import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; -import java.net.URI; - import static org.hamcrest.Matchers.is; public class TencentCloudEmbeddingsModelTests extends ESTestCase { @@ -32,16 +30,15 @@ public void testUri_UsesDefaultWhenNoOverride() { assertThat(model.getServiceSettings().modelId(), is("bge-m3")); } - public void testUri_UsesOverrideWhenProvided() { - var override = URI.create("http://custom.example.com/embeddings"); + public void testUri_UsesRegion() { var settings = new TencentCloudEmbeddingsServiceSettings( - new TencentCloudCommonServiceSettings("bge-m3", override, new RateLimitSettings(20)), + new TencentCloudCommonServiceSettings("bge-m3", "sh", new RateLimitSettings(20)), null, null, null ); var model = createModel(settings); - assertThat(model.uri(), is(override)); + assertThat(model.uri().toString(), is("https://sh.aisearch.tencentelasticsearch.com/v1/embeddings")); } public void testCopyConstructor_UpdatesServiceSettings() { 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 index f8dcf47eddb0a..b8101f9179ec4 100644 --- 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 @@ -13,7 +13,6 @@ import org.elasticsearch.xpack.inference.services.settings.RateLimitSettings; import org.elasticsearch.xpack.inference.services.tencentcloud.TencentCloudCommonServiceSettings; -import java.net.URI; import java.util.HashMap; import java.util.Map; @@ -27,10 +26,9 @@ public void testUri_UsesDefaultWhenNoOverride() { assertThat(model.uri().toString(), is("https://bj.aisearch.tencentelasticsearch.com/v1/rerank")); } - public void testUri_UsesOverrideWhenProvided() { - var override = URI.create("http://custom.example.com/v1/rerank"); - var model = createModel(new TencentCloudCommonServiceSettings("bge-reranker-large", override, new RateLimitSettings(20))); - assertThat(model.uri(), is(override)); + 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() { From 0958572be25298bdf5cace2e0b70d84b87e14cfd Mon Sep 17 00:00:00 2001 From: boicehuang Date: Sat, 25 Jul 2026 13:28:10 +0800 Subject: [PATCH 14/15] [Inference] Clarify rerank model support and region docs in TencentCloud service - Update rerankerWindowSize() comment: rerank supports multiple models (not a fixed list), model IDs are passed through to the gateway - Update MODEL_ID config description to mention the full list is in gateway documentation --- .../services/tencentcloud/TencentCloudService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 36392a16acfc1..5144203e08f63 100644 --- 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 @@ -253,8 +253,9 @@ public boolean usesParserForServiceSettings() { @Override public int rerankerWindowSize(String modelId) { - // BGE reranker models (bge-reranker-large, bge-reranker-v2-m3) support up to 512-token inputs. - // Using 1 token = 0.75 words, that is roughly 384 words. Use a conservative value with headroom. + // 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; } @@ -270,7 +271,9 @@ public static InferenceServiceConfiguration get() { 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, deepseek-v3, bge-reranker-v2-m3." + "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) From abed6e77c89179596abb47482069186bfdf4665e Mon Sep 17 00:00:00 2001 From: boicehuang Date: Sat, 25 Jul 2026 14:35:31 +0800 Subject: [PATCH 15/15] [Inference] Code review fixes: remove dead code, tighten visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete TencentCloudEndpointUtils.java (unused after URL→region migration) - Make COMPLETION_ERROR_PREFIX and USER_ROLE private in ActionCreator --- .workbuddy/memory/2026-07-25.md | 4 +- .../TencentCloudEndpointUtils.java | 73 ------------------- .../action/TencentCloudActionCreator.java | 4 +- 3 files changed, 4 insertions(+), 77 deletions(-) delete mode 100644 x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java diff --git a/.workbuddy/memory/2026-07-25.md b/.workbuddy/memory/2026-07-25.md index a1f5e84519a35..b10fac8d3d5f3 100644 --- a/.workbuddy/memory/2026-07-25.md +++ b/.workbuddy/memory/2026-07-25.md @@ -25,8 +25,8 @@ 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 硬编码:当前可配置,后续确认后决定 - 8. ⬜ rerank 模型范围:当前透传,后续确认 + 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 通过 diff --git a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java b/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java deleted file mode 100644 index 6837fb3df7a8a..0000000000000 --- a/x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/tencentcloud/TencentCloudEndpointUtils.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.ValidationException; - -import java.net.URI; -import java.util.Locale; - -/** - * Validates that a user-supplied endpoint URI for the TencentCloud AI Gateway inference service points to an - * approved Tencent-hosted host. This is the SSRF guard: because the framework only performs URI syntax parsing - * (see {@code ServiceUtils.convertToUri}) and the shared HTTP client accepts arbitrary hosts, each service that - * exposes a user-overridable URL must enforce its own allow-list here, before any secret is bound to a request. - */ -public final class TencentCloudEndpointUtils { - - private static final String TENCENT_ES_SUFFIX = ".tencentelasticsearch.com"; - private static final String TENCENT_ES_ROOT = "tencentelasticsearch.com"; - - private TencentCloudEndpointUtils() {} - - public static URI validateEndpoint(URI uri, String fieldName, String scope, ValidationException validationException) { - if (uri == null) { - return null; - } - String scheme = uri.getScheme(); - if ("https".equalsIgnoreCase(scheme) == false) { - validationException.addValidationError( - String.format(Locale.ROOT, "[%s] in [%s] must use the [https] scheme", fieldName, scope) - ); - return uri; - } - String host = uri.getHost(); - if (host == null || host.isBlank()) { - validationException.addValidationError(String.format(Locale.ROOT, "[%s] in [%s] must include a host", fieldName, scope)); - return uri; - } - String normalizedHost = normalizeHost(host); - // SSRF protection relies on the allow-list below: the host must be a *.tencentelasticsearch.com host. - // Any IP literal (private or public), localhost, or other non-Tencent host therefore fails this check, - // so there is no need for a separate blocked-host list. - if (isTencentElasticsearchHost(normalizedHost) == false) { - validationException.addValidationError( - String.format( - Locale.ROOT, - "[%s] in [%s] must point to a TencentCloud AI Gateway host ending with [%s]", - fieldName, - scope, - TENCENT_ES_SUFFIX - ) - ); - } - return uri; - } - - private static String normalizeHost(String host) { - String normalized = host.toLowerCase(Locale.ROOT); - if (normalized.endsWith(".")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - return normalized; - } - - private static boolean isTencentElasticsearchHost(String host) { - return host.equals(TENCENT_ES_ROOT) || host.endsWith(TENCENT_ES_SUFFIX); - } -} 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 index 281923901cba5..c33e238ae0b62 100644 --- 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 @@ -42,8 +42,8 @@ */ public class TencentCloudActionCreator implements TencentCloudActionVisitor { - public static final String COMPLETION_ERROR_PREFIX = "TencentCloud completions"; - public static final String USER_ROLE = "user"; + 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",