Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f2a21e9
Adding headers file
jonathan-buttner Feb 20, 2026
ebdb4a6
Using base class for task settings
jonathan-buttner Feb 23, 2026
bb0907e
Refactoring base class to allow for returning empty instance
jonathan-buttner Feb 23, 2026
8cedcd0
Fixing test failures
jonathan-buttner Feb 23, 2026
c4b846d
Fixing test
jonathan-buttner Feb 24, 2026
e3d466f
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Feb 24, 2026
bc2384e
Update docs/changelog/142969.yaml
jonathan-buttner Feb 24, 2026
e7a8fc2
[CI] Auto commit changes from spotless
Feb 24, 2026
56bf6dc
clean up and consolidating request header logic
jonathan-buttner Feb 24, 2026
1f64d15
Returning validation exception instead of xcontent when possible
jonathan-buttner Feb 24, 2026
4741068
Adding unwrapper utils tests
jonathan-buttner Feb 24, 2026
f1e92f7
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Feb 24, 2026
e55d554
Merge branch 'ia-azure-headers' of github.com:jonathan-buttner/elasti…
jonathan-buttner Feb 24, 2026
2eb28ce
Update x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/…
jonathan-buttner Feb 24, 2026
838ede1
Update x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/…
jonathan-buttner Feb 24, 2026
6242017
[CI] Auto commit changes from spotless
Feb 24, 2026
bfd1953
Address chatgpt feedback
jonathan-buttner Feb 24, 2026
3313304
Merge branch 'ia-azure-headers' of github.com:jonathan-buttner/elasti…
jonathan-buttner Feb 24, 2026
e13ba77
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Feb 25, 2026
4ae979a
Adding test for unwrap exception
jonathan-buttner Feb 25, 2026
569673e
Working refactor
jonathan-buttner Feb 26, 2026
f03baf4
Removing duplicate method
jonathan-buttner Feb 26, 2026
2d9f471
Adding tests for statefulvalue
jonathan-buttner Feb 26, 2026
efb0956
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Feb 26, 2026
7cb22c3
Using a single model reference
jonathan-buttner Feb 26, 2026
0b1cff0
Fixing test and renaming
jonathan-buttner Feb 27, 2026
80a2272
Fixing transport version
jonathan-buttner Feb 27, 2026
2b600d2
Fixing tests and refactoring validation
jonathan-buttner Feb 27, 2026
8ebbd3b
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Feb 27, 2026
710f8aa
[CI] Auto commit changes from spotless
Feb 27, 2026
83df8d5
Removing exception unwrap logic
jonathan-buttner Feb 27, 2026
179d7b2
Merge branch 'ia-azure-headers' of github.com:jonathan-buttner/elasti…
jonathan-buttner Feb 27, 2026
30d256d
Addressing feedback
jonathan-buttner Mar 2, 2026
fe979eb
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Mar 2, 2026
c75b7f4
[CI] Auto commit changes from spotless
Mar 2, 2026
e24b927
Allowing null in update api
jonathan-buttner Mar 2, 2026
3fe6c9e
Merge branch 'ia-azure-headers' of github.com:jonathan-buttner/elasti…
jonathan-buttner Mar 2, 2026
69b4732
Merge branch 'main' of github.com:elastic/elasticsearch into ia-azure…
jonathan-buttner Mar 3, 2026
ef2b448
Using orElse
jonathan-buttner Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/changelog/142969.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
area: Inference
issues: []
pr: 142969
summary: "[Inference API] Add custom headers for Azure OpenAI Service"
type: enhancement
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9304000
2 changes: 1 addition & 1 deletion server/src/main/resources/transport/upper_bounds/9.4.csv
Original file line number Diff line number Diff line change
@@ -1 +1 @@
query_dsl_boxplot_exponential_histogram_support,9303000
inference_azure_openai_task_settings_headers,9304000
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ public Settings getContentAsSettings() {
if (unvalidatedMap.containsKey(SERVICE_SETTINGS)) {
if (unvalidatedMap.get(SERVICE_SETTINGS) instanceof Map<?, ?> tempMap) {
for (Map.Entry<?, ?> entry : (tempMap).entrySet()) {
if (entry.getKey() instanceof String key && entry.getValue() instanceof Object value) {
serviceSettings.put(key, value);
if (entry.getKey() instanceof String key) {
serviceSettings.put(key, entry.getValue());
} else {
throw new ElasticsearchStatusException(
"Failed to parse update request [{}]",
Expand All @@ -154,8 +154,8 @@ public Settings getContentAsSettings() {
if (unvalidatedMap.containsKey(TASK_SETTINGS)) {
if (unvalidatedMap.get(TASK_SETTINGS) instanceof Map<?, ?> tempMap) {
for (Map.Entry<?, ?> entry : (tempMap).entrySet()) {
if (entry.getKey() instanceof String key && entry.getValue() instanceof Object value) {
taskSettings.put(key, value);
if (entry.getKey() instanceof String key) {
taskSettings.put(key, entry.getValue());
} else {
throw new ElasticsearchStatusException(
"Failed to parse update request [{}]",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* 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.common.parser;

import org.elasticsearch.common.ValidationException;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.xcontent.ConstructingObjectParser;
import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.ToXContentFragment;
import org.elasticsearch.xcontent.XContentBuilder;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
import static org.elasticsearch.xpack.inference.services.ServiceUtils.removeNullValues;
import static org.elasticsearch.xpack.inference.services.ServiceUtils.validateMapStringValues;

public record Headers(StatefulValue<Map<String, String>> mapValue) implements ToXContentFragment, Writeable {

// public for testing
public static final String HEADERS_FIELD = "headers";
// public for testing
public static final Headers UNDEFINED_INSTANCE = new Headers(StatefulValue.undefined());
public static final Headers NULL_INSTANCE = new Headers(StatefulValue.nullInstance());

/**
* Sentinel passed by the parser when the headers field is present with value null.
*/
public static final Object PARSER_NULL_SENTINEL = new HashMap<>();

private static final ParseField HEADERS = new ParseField(HEADERS_FIELD);

public static <Value, Context> void initParser(ConstructingObjectParser<Value, Context> parser) {
parser.declareObjectOrNull(optionalConstructorArg(), (p, c) -> {
var parsedMap = p.map();
if (parsedMap == null || parsedMap == PARSER_NULL_SENTINEL) {
return parsedMap;
}

var validationException = new ValidationException();

return doValidation(parsedMap, validationException);
}, PARSER_NULL_SENTINEL, HEADERS);
}

private static Map<String, String> doValidation(Map<String, Object> map, ValidationException validationException) {
removeNullValues(map);

var stringHeaders = validateMapStringValues(map, HEADERS.getPreferredName(), validationException, false, Map.of());

validationException.throwIfValidationErrorsExist();

return stringHeaders;
}

@SuppressWarnings("unchecked")
public static Headers create(Object arg, String path) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we always cast arg to Map<String, Object> if it's non-null, would it make sense to have the method just take a Map<String, Object> instead of Object?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping it as an object here helps with encapsulation I think. That way only Headers knows the expected type. If we have create() take a Map, then the caller needs to do the cast and have the uncheck cast suppression. It also goes from Map<String, Object> to Map<String, String> after the validation check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. Would it be worth adding some error handling for if we get a ClassCastException here, then, or is it fine to just throw the exception and let somewhere higher up deal with it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'll add an instanceof check.

// We will get null here if the headers field was not present in the json
if (arg == null) {
return UNDEFINED_INSTANCE;
}
Comment on lines +69 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an argument for returning EMPTY_INSTANCE here? I think it would allow us to make the headers field on AzureOpenAiTaskSettings not @nullable, so we'd have to check .isEmpty() instead of null in a few places, but it would mean that we wouldn't end up potentially creating a AzureOpenAiTaskSettings with a null user and empty headers, which could happen at the moment. Alternately, we could check if headers is null or empty in the places we're currently just checking if it's null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, now that I'm thinking about this more I wonder how a user would use the _update endpoint to remove headers 🤔

If we removed null as a valid state then I don't think we'd be able to differentiate between an update action with no headers and an update action to remove the headers.

I think we can use declareObjectOrNull and if the user explicitly set headers: null (so the parser sees a value_null we can return empty headers which can wipe out the headers. If the user does not specify headers then we'll see it as null it just use the existing task setting headers.

I agree though, it'd be nice to have a single state with only empty headers. Let me know if you can think of a way to handle that. I suppose we could use an enum as well 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok give this another look, I think it's in a better state 😅


if (arg == PARSER_NULL_SENTINEL) {
return NULL_INSTANCE;
}

var validationException = new ValidationException();

if (arg instanceof Map == false) {
validationException.addValidationError(ObjectParserUtils.invalidTypeErrorMsg(HEADERS_FIELD, path, arg, "Map"));
throw validationException;
}

// It's not likely that this create method would be called with invalid values since they should be validated during parsing but
// we'll do it just in case this method is used elsewhere
var stringsMap = doValidation((Map<String, Object>) arg, validationException);

if (stringsMap.isEmpty()) {
// If a user specifies "headers": {} we'll assume they don't want any headers. If this in the context of an update API,
// this is the same as if they did "headers": null which means to remove all existing headers.
return NULL_INSTANCE;
}

return new Headers(StatefulValue.of(stringsMap));
}

public Headers {
Objects.requireNonNull(mapValue);
}

public Headers(StreamInput in) throws IOException {
this(StatefulValue.read(in, input -> input.readImmutableMap(StreamInput::readString, StreamInput::readString)));
}

public boolean isEmpty() {
return mapValue.isPresent() == false || mapValue.get().isEmpty();
}

public boolean isPresent() {
return mapValue.isPresent();
}

public boolean isNull() {
return mapValue.isNull();
}

@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
if (isEmpty() == false) {
builder.field(HEADERS.getPreferredName(), mapValue.get());
}
return builder;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
StatefulValue.write(
out,
mapValue,
(streamOutput, v) -> streamOutput.writeMap(v, StreamOutput::writeString, StreamOutput::writeString)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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.common.parser;

import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;

import java.io.IOException;
import java.util.NoSuchElementException;
import java.util.Objects;

/**
* This class holds a value of type {@param T} that can be in one of three states: undefined, null, or defined with a non-null value.
* It provides methods to check the state and retrieve the value if present.
* <p>
* Undefined means that the value is not defined aka it was absent in the input
* Null means that the value is defined but explicitly set to null
* Present means that the value is defined and not null
* @param <T> the type of the value
*/
public final class StatefulValue<T> {

static final NoSuchElementException NO_VALUE_PRESENT = new NoSuchElementException("No value present");

private static final StatefulValue<?> UNDEFINED_INSTANCE = new StatefulValue<>(null, false);
private static final StatefulValue<?> NULL_INSTANCE = new StatefulValue<>(null, true);

public static <T> StatefulValue<T> undefined() {
@SuppressWarnings("unchecked")
var absent = (StatefulValue<T>) UNDEFINED_INSTANCE;
return absent;
}

public static <T> StatefulValue<T> nullInstance() {
@SuppressWarnings("unchecked")
var nullInstance = (StatefulValue<T>) NULL_INSTANCE;
return nullInstance;
}

public static <T> StatefulValue<T> of(T value) {
return new StatefulValue<>(Objects.requireNonNull(value), true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor nitpick, but if we want to more closely align with the behaviour of Optional, we could throw new NoSuchElementException("No value present") here instead of an NPE.

}

public static <T> StatefulValue<T> read(StreamInput in, Writeable.Reader<T> reader) throws IOException {
var isDefined = in.readBoolean();
if (isDefined == false) {
return undefined();
}

var isNull = in.readBoolean();
if (isNull) {
return nullInstance();
}

var value = reader.read(in);
return of(value);
}

public static <T> void write(StreamOutput out, StatefulValue<T> statefulValue, Writeable.Writer<T> writer) throws IOException {
out.writeBoolean(statefulValue.isDefined);
if (statefulValue.isDefined) {
out.writeBoolean(statefulValue.isNull());
if (statefulValue.isPresent()) {
writer.write(out, statefulValue.value);
}
}
}

private final T value;
private final boolean isDefined;

private StatefulValue(T value, boolean isDefined) {
this.value = value;
this.isDefined = isDefined;
}

/**
* Returns true if the value is not defined, meaning it is absent.
*/
public boolean isUndefined() {
return isDefined == false;
}

/**
* Returns true if the value is defined and explicitly set to null.
*/
public boolean isNull() {
return isDefined && value == null;
}

/**
* Returns true if the value is defined and not null.
*/
public boolean isPresent() {
return isDefined && value != null;
}

public T get() {
if (isPresent() == false) {
throw NO_VALUE_PRESENT;
}
return value;
}

public T orElse(T other) {
return isPresent() ? value : other;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
StatefulValue<?> statefulValue = (StatefulValue<?>) o;
return Objects.equals(value, statefulValue.value) && isDefined == statefulValue.isDefined;
}

@Override
public int hashCode() {
return Objects.hash(value, isDefined);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
Expand Down Expand Up @@ -641,7 +642,7 @@ public static void validateMapValues(
settingName,
entry.getKey(),
entry.getValue(),
entry.getValue(),
getTypeAsString(entry.getValue()),
String.join(", ", validTypesAsStrings)
);
}
Expand All @@ -657,6 +658,29 @@ public static void validateMapValues(
}
}

private static String getTypeAsString(@Nullable Object value) {
if (value == null) {
return "null";
}

var simpleName = value.getClass().getSimpleName();
var lowerCaseSimpleName = simpleName.toLowerCase(Locale.ROOT);

// maps may be represented as HashMap, LinkedHashMap, Map1, etc. Lists may be ArrayList, LinkedList, etc.
// Sets may be HashSet, LinkedHashSet, etc. We want to simplify these to Map, List, and Set in the error messages.
if (lowerCaseSimpleName.contains("map")) {
return "Map";
} else if (lowerCaseSimpleName.contains("list")) {
return "Array";
} else if (lowerCaseSimpleName.contains("set")) {
return "Set";
} else if (lowerCaseSimpleName.contains("array")) {
return "Array";
} else {
return simpleName;
}
}

public static Map<String, SecureString> convertMapStringsToSecureString(
Map<String, ?> map,
String settingName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import org.elasticsearch.common.ValidationException;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.inference.ModelConfigurations;
import org.elasticsearch.xpack.inference.services.azureopenai.embeddings.AzureOpenAiEmbeddingsRequestTaskSettings;

import java.util.Map;

Expand All @@ -31,7 +30,7 @@ public record AzureAiStudioEmbeddingsRequestTaskSettings(@Nullable String user)
* does not throw an error.
*
* @param map the settings received from a request
* @return a {@link AzureOpenAiEmbeddingsRequestTaskSettings}
* @return a {@link AzureAiStudioEmbeddingsRequestTaskSettings}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was referencing the wrong class

*/
public static AzureAiStudioEmbeddingsRequestTaskSettings fromMap(Map<String, Object> map) {
if (map.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ public AzureOpenAiRateLimitServiceSettings rateLimitServiceSettings() {
return rateLimitServiceSettings;
}

@Override
public AzureOpenAiSecretSettings getSecretSettings() {
return (AzureOpenAiSecretSettings) super.getSecretSettings();
}

@Override
public RateLimitSettings rateLimitSettings() {
return rateLimitServiceSettings.rateLimitSettings();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ public void parseRequestConfig(

throwIfNotEmptyMap(config, NAME);
throwIfNotEmptyMap(serviceSettingsMap, NAME);
throwIfNotEmptyMap(taskSettingsMap, NAME);
// The new approach is to leverage a ConstructingObjectParser to parse the task settings, this does not mutate the original map
// so we don't need to check if it's empty after parsing. The ConstructingObjectParser will throw an exception if there are any
// unrecognized fields in the task settings

parsedModelListener.onResponse(model);
} catch (Exception e) {
Expand Down
Loading