Repository navigation
Fix dimensions_set_by_user for SageMaker OpenAI - #150834
DonalEvans merged 5 commits into
Conversation
ApiServiceSettings.toXContent only wrote `dimensions`, so persisting an endpoint whose dimensions were auto-discovered (dimensionsSetByUser=false) and re-parsing via fromMap recomputed the flag as `dimensions != null`, flipping it to true. Persist `dimensions_set_by_user` and read it back (falling back to the previous inference when absent, for user requests and already-persisted configs). The flag is internal, so it is excluded from the filtered GET response: SageMakerServiceSettings now renders the api settings' getFilteredXContentObject(), and the OpenAI settings override it to omit the flag while keeping it in the persisted toXContent.
|
Pinging @elastic/search-inference-team (Team:Search - Inference) |
DonalEvans
left a comment
There was a problem hiding this comment.
In order to fully correct the behaviour here, the ConfigurationParseContext needs to passed into the fromMap() implementation on OpenAiTextEmbeddingPayload.ApiServiceSettings. This allows us to know whether we're dealing with a user request (where dimensions_set_by_user should NOT be parsed from the map, because it's not a setting that should be directly user-settable) or parsing persisted objects (where dimensions_set_by_user should be a required field). With the code as it currently is, a user could specify dimensions in a request but also include "dimensions_set_by_user": false, which is obviously not right. To cover this behaviour, we should have new tests of the ApiServiceSettings.fromMap() method to confirm that requests are parsed correctly.
We should also add some tests to SageMakerServiceTests to confirm that specifying the dimensions results in the correct dimensions value being used, and that specifying dimensions_set_by_user in a request context (i.e. via calling parseRequestConfig()) results in an error. An example of how the second test could be written is:
public void testParseRequestConfig_TextEmbedding_DimensionsSetByUser_ReturnsError() {
var modelBuilder = new SageMakerModelBuilder(new SageMakerSchemas());
var sageMakerService = new SageMakerService(modelBuilder, client, schemas, sageMakerThreadPool, Map::of, mockClusterServiceEmpty());
TestPlainActionFuture<Model> listener = new TestPlainActionFuture<>();
var serviceSettings = new HashMap<>(Map.of(ACCESS_KEY_FIELD, "api_key",
SECRET_KEY_FIELD, "secret_key",
"endpoint_name", "endpoint",
"api", "openai",
"region", "region",
DIMENSIONS_SET_BY_USER, true
));
sageMakerService.parseRequestConfig("modelId", TaskType.TEXT_EMBEDDING, new HashMap<>(Map.of("service_settings", serviceSettings)), listener);
var exception = expectThrows(ElasticsearchStatusException.class, () -> listener.actionGet(TEST_REQUEST_TIMEOUT));
assertThat(exception.status(), is(RestStatus.BAD_REQUEST));
assertThat(exception.getMessage(), is("Configuration contains settings [{dimensions_set_by_user=true}] unknown to the [sagemaker] service"));
}
While we're at it, it would be good to add tests for parsePersistedConfig() as well, to ensure that we don't return an error when dimensions_set_by_user is present in the map we parse for text_embedding models.
Regarding ElasticTextEmbeddingPayload.ApiServiceSettings, let's also fix that class. It has a slightly different bug in its fromMap() implementation where if only considers the parsed value of dimensionsSetByUser but not whether dimensions has actually been set, but will also need to be refactored to pass the appropriate ConfigurationParseContext in to the method and should have similar tests to the ones added for OpenAiTextEmbeddingPayload.
| @@ -134,23 +134,31 @@ public void writeTo(StreamOutput out) throws IOException { | |||
|
|
|||
| @Override | |||
| public ToXContentObject getFilteredXContentObject() { | |||
There was a problem hiding this comment.
For consistency with other integrations, I think it would be better to have SageMakerServiceSettings extend FilteredXContentObject and implement the toXContentFragmentOfExposedFields() method instead of getFilteredXContentObject(). This is the approach used by almost all other service settings classes to exclude fields from the XContent.
There was a problem hiding this comment.
Keeping the current structure per your call in the conversation — thanks!
| return builder; | ||
| } | ||
|
|
||
| @Override | ||
| public ToXContentObject getFilteredXContentObject() { |
There was a problem hiding this comment.
The ApiServiceSettings class could also be changed to extend FilteredXContentObject and implement toXContentFragmentOfExposedFields() instead of getFilteredXContentObject().
There was a problem hiding this comment.
Keeping the getFilteredXContentObject() override per the same call — FilteredXContentObject#getFilteredXContentObject() wraps in startObject()/endObject(), which would break these flat fragments.
| public void testDimensionsNotSetByUserSurvivesXContentRoundTrip() throws IOException { | ||
| // An endpoint whose dimensions were auto-discovered (not set by the user) persists its dimensions, but must | ||
| // round-trip with dimensionsSetByUser=false; otherwise the stored config is wrongly re-read as user-set. | ||
| var autoDiscovered = new OpenAiTextEmbeddingPayload.ApiServiceSettings(randomIntBetween(1, 100), false); | ||
| var roundTripped = fromMutableMap(new HashMap<>(toMap(autoDiscovered))); | ||
| assertThat(roundTripped.dimensionsSetByUser(), equalTo(false)); | ||
| assertThat(roundTripped, equalTo(autoDiscovered)); | ||
| } |
There was a problem hiding this comment.
The behaviour in this test is already covered by InferenceSettingsTestCase.testXContentRoundTrip() on the base class, so I think this test is redundant.
| return new ApiServiceSettings(dimensions, dimensions != null); | ||
| // dimensions_set_by_user is persisted with stored configurations; it is absent from user requests, where it is | ||
| // inferred from whether dimensions were supplied. Reading it back preserves the distinction across a persist/parse cycle. | ||
| return new ApiServiceSettings(dimensions, dimensionsSetByUser != null ? dimensionsSetByUser : dimensions != null); |
There was a problem hiding this comment.
Since the dimensionsSetByUser field on ApiServiceSettings can never be null, it should probably be boolean rather than Boolean in the constructor.
There was a problem hiding this comment.
Kept as a record Boolean component since the accessor has to override ServiceSettings#dimensionsSetByUser(), which returns Boolean — covered by your call to keep the structure as-is.
Thread ConfigurationParseContext through the SageMaker schema so the text-embedding payloads parse dimensions_set_by_user only from persisted configs; in a request it is left unparsed and rejected as an unknown setting. Apply the same fix to ElasticTextEmbeddingPayload (which also no longer ignores whether dimensions was set) and hide the flag from its GET response. Add request/persisted tests at the service and settings level, and drop the round-trip test already covered by the base case.
|
Thanks for the thorough review @DonalEvans! Pushed in 5a0945c. Done:
Two points I'd like your call on — I ran into a constraint with
If you'd prefer I convert the payloads to classes to adopt the |
I think we're fine keeping the structure as it is; refactoring these classes too heavily is outside the scope of this PR and the Sagemaker service is already doing a lot of things differently from other services, so some inconsistency is probably unavoidable. I do think that |
|
@elasticsearchmachine test this |
| dimensionsSetByUser = dimensions != null; | ||
| } else { | ||
| var storedDimensionsSetByUser = extractOptionalBoolean(serviceSettings, DIMENSIONS_SET_BY_USER_FIELD, validationException); | ||
| dimensionsSetByUser = storedDimensionsSetByUser != null ? storedDimensionsSetByUser : dimensions != null; |
There was a problem hiding this comment.
dimensions_set_by_user should never be null in a persisted context, since there has never been a time when it didn't exist for this class. If it's not present, then we should return an error, similar to what we do in GoogleVertexAiEmbeddingsServiceSettings.fromMap().
There was a problem hiding this comment.
Done in 61467d2 — in a persisted context a missing value now adds a validation error, mirroring GoogleVertexAiEmbeddingsServiceSettings.fromMap().
| // In a persisted config we read the stored value, falling back to that same inference for configs written before it existed. | ||
| boolean dimensionsSetByUser; | ||
| if (ConfigurationParseContext.isRequestContext(context)) { | ||
| dimensionsSetByUser = dimensions != null; | ||
| } else { | ||
| var storedDimensionsSetByUser = extractOptionalBoolean( | ||
| serviceSettings, | ||
| ServiceFields.DIMENSIONS_SET_BY_USER, | ||
| validationException | ||
| ); | ||
| dimensionsSetByUser = storedDimensionsSetByUser != null ? storedDimensionsSetByUser : dimensions != null; | ||
| } |
There was a problem hiding this comment.
In a persisted context, dimensions should always be non-null, because even if a user doesn't set a value when creating the endpoint, we infer it from the validation call we do as part of endpoint creation. As such, the dimensions != null check will always resolve to true. In other integrations, we default to false when no value for dimensions_set_by_user was written (see BaseJinaAIEmbeddingsServiceSettings.fromMap()) so it would probably be better to do that here too.
There was a problem hiding this comment.
Done in 61467d2 — defaults to false when absent from a persisted config, mirroring BaseJinaAIEmbeddingsServiceSettings.fromMap().
…dimensions-set-by-user
In a persisted config the OpenAI settings now default a missing dimensions_set_by_user to false (as BaseJinaAIEmbeddingsServiceSettings does), since dimensions is always present after endpoint validation and falling back to dimensions != null always resolved to true. The Elastic settings treat the field as required and add a validation error when it is missing (as GoogleVertexAiEmbeddingsServiceSettings does), since this class has always persisted the field.
|
Pushed 61467d2 addressing both inline comments:
Both are covered by new persisted-context tests ( I also merged |
|
@elasticsearchmachine test this |
|
The serverless tests all passed, but the status isn't being reflected in GitHub for some reason, so I'll merge it. |
* upstream/main: (523 commits) Upgrade netty to 4.1.135.Final (elastic#151099) ES|QL: Add benchmark for match at runtime (elastic#151071) ES|QL: Unmute test for elastic#150965 (elastic#151123) ES|QL: make the worker queue dynamic (elastic#151103) ES|QL: Add `briefSummary` element to FunctionInfo for all functions, add one-line summaries to overview lists (elastic#150546) Mute org.elasticsearch.xpack.esql.qa.multi_node.EsqlSpecIT test {csv-spec:approximation.Approximate stats by with zero variance} elastic#151120 Mute org.elasticsearch.compute.operator.topn.TopNOperatorTests testSplitOnSize elastic#151118 Mute org.elasticsearch.backwards.MixedClusterClientYamlTestSuiteIT test {p0=index/100_field_name_length_limit/Test field name length limit synthetic source} elastic#151117 Mute org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.DivTests testEvaluate {TestCase=<long>, <dense_vector>} elastic#151116 Mute org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.DivTests testEvaluate {TestCase=<double>, <dense_vector>} elastic#151115 Mute org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.DivTests testEvaluate {TestCase=<integer>, <dense_vector>} elastic#151114 Mute org.elasticsearch.telemetry.apm.internal.export.otelsdk.OtelSdkExportMeterSupplierTests testSpanProcessorSelfMonitoringMetricsFlowIntoHealthProvider elastic#151113 Fix ES94BloomFilterDocValuesFormatTests test failu (elastic#151084) Fix RandomizedTimeSeriesIT test failure (elastic#151082) Fix dimensions_set_by_user for SageMaker OpenAI (elastic#150834) Unmute some tests (elastic#151081) ESQL: Fix TSTEP auto-@timestamp import (elastic#151044) Reuse docs ids array in LuceneOperator (elastic#150828) Mute org.elasticsearch.test.apmintegration.OTelMetricsBufferingIT testOutageBuffersToDiskAndDrainsOnRecovery elastic#150387 Setting for trace sampling and batch processor (elastic#150876) ...
ApiServiceSettings.toXContent only wrote `dimensions`, so persisting an endpoint whose dimensions were auto-discovered (dimensionsSetByUser=false) and re-parsing via fromMap recomputed the flag as `dimensions != null`, flipping it to true. Persist `dimensions_set_by_user` and read it back (falling back to the previous inference when absent, for user requests and already-persisted configs). The flag is internal, so it is excluded from the filtered GET response: SageMakerServiceSettings now renders the api settings' getFilteredXContentObject(), and the OpenAI settings override it to omit the flag while keeping it in the persisted toXContent. Thread ConfigurationParseContext through the SageMaker schema so the text-embedding payloads parse dimensions_set_by_user only from persisted configs; in a request it is left unparsed and rejected as an unknown setting. Apply the same fix to ElasticTextEmbeddingPayload (which also no longer ignores whether dimensions was set) and hide the flag from its GET response. Add request/persisted tests at the service and settings level, and drop the round-trip test already covered by the base case. In a persisted config the OpenAI settings now default a missing dimensions_set_by_user to false (as BaseJinaAIEmbeddingsServiceSettings does), since dimensions is always present after endpoint validation and falling back to dimensions != null always resolved to true. The Elastic settings treat the field as required and add a validation error when it is missing (as GoogleVertexAiEmbeddingsServiceSettings does), since this class has always persisted the field.
Summary
OpenAiTextEmbeddingPayload.ApiServiceSettingslost thedimensionsSetByUserflag across a persist → re-parse cycle.toXContentonly wrotedimensions, so when an endpoint whose dimensions were auto-discovered (updateModelWithEmbeddingDetails(...)constructsApiServiceSettings(dimensions, false)) was persisted and re-read,fromMaprecomputed the flag asdimensions != null, flipping it totrue.Closes #146881
What changed
ConfigurationParseContextis threaded throughSageMakerSchemaPayload#apiServiceSettings(...)(andSageMakerSchema/SageMakerServiceSettings.fromMap), withSageMakerModelBuilderpassingREQUESTfromfromRequestandPERSISTENTfromfromStorage.OpenAiTextEmbeddingPayload.ApiServiceSettingspersistsdimensions_set_by_userintoXContent. In a request the field is not parsed — a user-supplied value is left in the map and rejected as an unknown setting — and the flag is derived from whetherdimensionswas provided. In a persisted config the stored value is read back, defaulting to false when absent (configs persisted before the field existed), asBaseJinaAIEmbeddingsServiceSettingsdoes.ElasticTextEmbeddingPayload.ApiServiceSettingsgets the same parse-context handling, and its request parsing no longer ignores whetherdimensionswas actually set. Since this class has always persisted the flag, a persisted config missing it now adds a validation error, asGoogleVertexAiEmbeddingsServiceSettingsdoes.getFilteredXContentObject()to omit it from the GET response, andSageMakerServiceSettingsrenders the api settings' filtered view rather thanthis.Tests
dimensions_set_by_userinparseRequestConfigreturns a 400 (SageMakerServiceTests); the flag is derived fromdimensionspresence and the key is left unconsumed (SageMaker{OpenAi,ElasticTextEmbedding}ServiceSettingsTests).parsePersistedConfigaccepts stored configs containing the flag (SageMakerServiceTests).testFilteredXContentObjectOmitsDimensionsSetByUserverifies the GET view omits the flag while the persisted form keeps it, andrandomApiServiceSettings()was widened so the inherited XContent round-trip also exercises the auto-discovered case.