From 14a138ffa820f8edfc5de084782e7a06e0db7600 Mon Sep 17 00:00:00 2001 From: guanxu <1510424541@qq.com> Date: Thu, 16 Jul 2026 16:58:57 +0800 Subject: [PATCH 1/2] Support `extraBody` parameter for `OpenAiEmbeddingModel` Signed-off-by: guanxu <1510424541@qq.com> --- .../ai/openai/OpenAiChatModel.java | 5 +- .../ai/openai/OpenAiEmbeddingOptions.java | 50 ++++++++++++++++++- .../OpenAiEmbeddingOptionsTests.java | 42 ++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java index 7add8ccc47..e0fc441fa7 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java @@ -905,11 +905,10 @@ else if (json.equals("required")) { // Add extraBody parameters as additional body properties for OpenAI-compatible // providers if (requestOptions.getExtraBody() != null && !requestOptions.getExtraBody().isEmpty()) { - Map extraParams = requestOptions.getExtraBody() + Map extraParams = requestOptions.getExtraBody() .entrySet() .stream() - .collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, - entry -> com.openai.core.JsonValue.from(entry.getValue()))); + .collect(Collectors.toMap(Map.Entry::getKey, entry -> JsonValue.from(entry.getValue()))); builder.additionalBodyProperties(extraParams); } diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingOptions.java index 8bedd7c60a..9f117c2968 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiEmbeddingOptions.java @@ -22,8 +22,10 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import com.openai.azure.AzureOpenAIServiceVersion; +import com.openai.core.JsonValue; import com.openai.credential.Credential; import com.openai.models.embeddings.EmbeddingCreateParams; import com.openai.models.embeddings.EmbeddingModel; @@ -39,6 +41,7 @@ * @author Christian Tzolov * @author Ilayaperumal Gopinathan * @author Sebastien Deleuze + * @author guan xu */ public class OpenAiEmbeddingOptions extends AbstractOpenAiOptions implements EmbeddingOptions { @@ -61,18 +64,28 @@ public class OpenAiEmbeddingOptions extends AbstractOpenAiOptions implements Emb */ private final @Nullable Integer dimensions; + /** + * Extra parameters that are not part of the standard OpenAI API. These parameters are + * passed as additional body properties to support OpenAI-compatible providers like + * vLLM, Ollama, Groq, etc. that support custom parameters such as top_k, + * repetition_penalty, etc. + */ + private final @Nullable Map extraBody; + protected OpenAiEmbeddingOptions(@Nullable String baseUrl, @Nullable String apiKey, @Nullable Credential credential, @Nullable String model, @Nullable String microsoftDeploymentName, @Nullable AzureOpenAIServiceVersion microsoftFoundryServiceVersion, @Nullable String organizationId, @Nullable Boolean isMicrosoftFoundry, @Nullable Boolean isGitHubModels, @Nullable Duration timeout, @Nullable Integer maxRetries, @Nullable Proxy proxy, @Nullable Map customHeaders, - @Nullable String user, @Nullable EncodingFormat encodingFormat, @Nullable Integer dimensions) { + @Nullable String user, @Nullable EncodingFormat encodingFormat, @Nullable Integer dimensions, + @Nullable Map extraBody) { super(baseUrl, apiKey, credential, model != null ? model : DEFAULT_EMBEDDING_MODEL, microsoftDeploymentName, microsoftFoundryServiceVersion, organizationId, isMicrosoftFoundry, isGitHubModels, timeout, maxRetries, proxy, customHeaders); this.user = user; this.encodingFormat = encodingFormat; this.dimensions = dimensions; + this.extraBody = (extraBody != null ? Map.copyOf(extraBody) : null); } public static Builder builder() { @@ -92,6 +105,10 @@ public static Builder builder() { return this.dimensions; } + public @Nullable Map getExtraBody() { + return this.extraBody; + } + public EmbeddingCreateParams toOpenAiCreateParams(List instructions) { EmbeddingCreateParams.Builder builder = EmbeddingCreateParams.builder(); @@ -117,6 +134,17 @@ else if (this.getModel() != null) { if (this.getDimensions() != null) { builder.dimensions(this.getDimensions()); } + + // Add extraBody parameters as additional body properties for OpenAI-compatible + // providers + if (this.getExtraBody() != null && !this.getExtraBody().isEmpty()) { + Map extraParams = this.getExtraBody() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> JsonValue.from(entry.getValue()))); + builder.additionalBodyProperties(extraParams); + } + return builder.build(); } @@ -147,6 +175,8 @@ public static final class Builder extends AbstractBuilder extraBody; + public Builder from(OpenAiEmbeddingOptions fromOptions) { // Parent class fields this.baseUrl = fromOptions.getBaseUrl(); @@ -166,6 +196,7 @@ public Builder from(OpenAiEmbeddingOptions fromOptions) { this.user = fromOptions.getUser(); this.encodingFormat = fromOptions.getEncodingFormat(); this.dimensions = fromOptions.getDimensions(); + this.extraBody = fromOptions.getExtraBody(); return this; } @@ -223,6 +254,16 @@ public Builder merge(@Nullable EmbeddingOptions from) { if (castFrom.getEncodingFormat() != null) { this.encodingFormat = castFrom.getEncodingFormat(); } + if (castFrom.getExtraBody() != null) { + if (this.extraBody == null) { + this.extraBody = new HashMap<>(castFrom.getExtraBody()); + } + else { + Map merged = new HashMap<>(this.extraBody); + merged.putAll(castFrom.getExtraBody()); + this.extraBody = merged; + } + } } return this; } @@ -257,12 +298,17 @@ public Builder dimensions(@Nullable Integer dimensions) { return this; } + public Builder extraBody(@Nullable Map extraBody) { + this.extraBody = extraBody; + return this; + } + @Override public OpenAiEmbeddingOptions build() { return new OpenAiEmbeddingOptions(this.baseUrl, this.apiKey, this.credential, this.model, this.microsoftDeploymentName, this.microsoftFoundryServiceVersion, this.organizationId, this.isMicrosoftFoundry, this.isGitHubModels, this.timeout, this.maxRetries, this.proxy, - this.customHeaders, this.user, this.encodingFormat, this.dimensions); + this.customHeaders, this.user, this.encodingFormat, this.dimensions, this.extraBody); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingOptionsTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingOptionsTests.java index 91cd086de8..44a9207fc9 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingOptionsTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/embedding/OpenAiEmbeddingOptionsTests.java @@ -99,4 +99,46 @@ void testOptionsBuilderMergeCustomHeaders() { .containsEntry("merged-header2", "merged-value2"); } + @Test + void extraBodyIsPassedAsAdditionalBodyProperties() { + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() + .model("test-model") + .extraBody(Map.of("top_k", 40, "repetition_penalty", 1.2)) + .build(); + + EmbeddingCreateParams createParams = options.toOpenAiCreateParams(List.of("test input")); + + assertThat(options.getExtraBody()).containsEntry("top_k", 40).containsEntry("repetition_penalty", 1.2); + assertThat(createParams._additionalBodyProperties()).containsKeys("top_k", "repetition_penalty"); + assertThat(createParams._additionalBodyProperties().get("top_k").asNumber().get()).isEqualTo(40); + assertThat(createParams._additionalBodyProperties().get("repetition_penalty").asNumber().get()).isEqualTo(1.2); + } + + @Test + void extraBodyIsCopiedAndMerged() { + OpenAiEmbeddingOptions source = OpenAiEmbeddingOptions.builder() + .model("test-model") + .extraBody(Map.of("top_k", 40)) + .build(); + + OpenAiEmbeddingOptions copied = OpenAiEmbeddingOptions.builder().from(source).build(); + OpenAiEmbeddingOptions merged = OpenAiEmbeddingOptions.builder() + .extraBody(Map.of("repetition_penalty", 1.2)) + .merge(source) + .build(); + + assertThat(copied.getExtraBody()).containsEntry("top_k", 40); + assertThat(merged.getExtraBody()).containsEntry("top_k", 40).containsEntry("repetition_penalty", 1.2); + } + + @Test + void emptyExtraBodyIsNotAddedToCreateParams() { + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder().model("test-model").build(); + + EmbeddingCreateParams createParams = options.toOpenAiCreateParams(List.of("test input")); + + assertThat(options.getExtraBody()).isNull(); + assertThat(createParams._additionalBodyProperties()).isEmpty(); + } + } From 649feb268a19e88574e93cdc59ba264850770243 Mon Sep 17 00:00:00 2001 From: guanxu <1510424541@qq.com> Date: Thu, 16 Jul 2026 22:51:08 +0800 Subject: [PATCH 2/2] Support `extraBody` parameter for `OpenAiEmbeddingAutoConfiguration` Signed-off-by: guanxu <1510424541@qq.com> --- .../OpenAiEmbeddingProperties.java | 28 +++++++++++++++ .../OpenAiChatAutoConfigurationIT.java | 35 +++++++++++++++++++ .../OpenAiChatPropertiesTests.java | 31 ++++++++++++++++ .../OpenAiEmbeddingAutoConfigurationIT.java | 33 +++++++++++++++++ .../OpenAiEmbeddingPropertiesTests.java | 31 ++++++++++++++++ .../api/embeddings/openai-embeddings.adoc | 1 + 6 files changed, 159 insertions(+) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingProperties.java index 68016c0ebb..8fde7411a6 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingProperties.java @@ -16,6 +16,8 @@ package org.springframework.ai.model.openai.autoconfigure; +import java.util.Map; + import org.jspecify.annotations.Nullable; import org.springframework.ai.document.MetadataMode; @@ -24,6 +26,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.DeprecatedConfigurationProperty; +/** + * OpenAI SDK Embedding autoconfiguration properties. + * + * @author guan xu + */ @ConfigurationProperties(OpenAiEmbeddingProperties.CONFIG_PREFIX) public class OpenAiEmbeddingProperties extends AbstractOpenAiProperties { @@ -39,6 +46,8 @@ public class OpenAiEmbeddingProperties extends AbstractOpenAiProperties { private @Nullable Integer dimensions; + private @Nullable Map extraBody; + public MetadataMode getMetadataMode() { return this.metadataMode; } @@ -79,12 +88,21 @@ public void setDimensions(@Nullable Integer dimensions) { this.dimensions = dimensions; } + public @Nullable Map getExtraBody() { + return this.extraBody; + } + + public void setExtraBody(@Nullable Map extraBody) { + this.extraBody = extraBody; + } + public OpenAiEmbeddingOptions toOptions() { return OpenAiEmbeddingOptions.builder() .model(this.model) .user(this.user) .encodingFormat(this.encodingFormat) .dimensions(this.dimensions) + .extraBody(this.extraBody) .build(); } @@ -142,6 +160,16 @@ public void setDimensions(@Nullable Integer dimensions) { OpenAiEmbeddingProperties.this.setDimensions(dimensions); } + @DeprecatedConfigurationProperty(replacement = "spring.ai.openai.embedding.extra-body") + @Deprecated(since = "2.0.0", forRemoval = true) + public @Nullable Map getExtraBody() { + return OpenAiEmbeddingProperties.this.getExtraBody(); + } + + public void setExtraBody(@Nullable Map extraBody) { + OpenAiEmbeddingProperties.this.setExtraBody(extraBody); + } + } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfigurationIT.java index 4a21ce8d41..e070ba566d 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfigurationIT.java @@ -33,6 +33,11 @@ import static org.assertj.core.api.Assertions.assertThat; +/** + * Integration tests for {@link OpenAiEmbeddingAutoConfiguration}. + * + * @author guan xu + */ @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class OpenAiChatAutoConfigurationIT { @@ -127,4 +132,34 @@ void chatActivation() { } + @Test + public void chatExtraBodyTest() { + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=http://TEST.BASE.URL", + + "spring.ai.openai.chat.extra-body.key1=value1", + "spring.ai.openai.chat.extra-body.key2=123", + "spring.ai.openai.chat.extra-body.nested.key3=true" + ) + // @formatter:on + .withConfiguration( + AutoConfigurations.of(OpenAiChatAutoConfiguration.class, ToolCallingAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + + assertThat(chatProperties.getExtraBody()).isNotNull(); + assertThat(chatProperties.getExtraBody()).containsEntry("key1", "value1"); + assertThat(chatProperties.getExtraBody()).containsEntry("key2", "123"); + assertThat(chatProperties.getExtraBody()).containsKey("nested"); + + var options = chatProperties.toOptions(); + assertThat(options.getExtraBody()).isNotNull(); + assertThat(options.getExtraBody()).containsEntry("key1", "value1"); + assertThat(options.getExtraBody()).containsEntry("key2", "123"); + assertThat(options.getExtraBody()).containsKey("nested"); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatPropertiesTests.java index 8d093fe002..2075bfeefd 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatPropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatPropertiesTests.java @@ -141,4 +141,35 @@ public void chatToolChoiceStringValuesTest() { } } + @Test + public void chatExtraBodyTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=http://TEST.BASE.URL", + + "spring.ai.openai.chat.extra-body.key1=value1", + "spring.ai.openai.chat.extra-body.key2=123", + "spring.ai.openai.chat.extra-body.nested.key3=true" + ) + // @formatter:on + .withConfiguration( + AutoConfigurations.of(OpenAiChatAutoConfiguration.class, ToolCallingAutoConfiguration.class)) + .run(context -> { + var chatProperties = context.getBean(OpenAiChatProperties.class); + + assertThat(chatProperties.getExtraBody()).isNotNull(); + assertThat(chatProperties.getExtraBody()).containsEntry("key1", "value1"); + assertThat(chatProperties.getExtraBody()).containsEntry("key2", "123"); + assertThat(chatProperties.getExtraBody()).containsKey("nested"); + + var options = chatProperties.toOptions(); + assertThat(options.getExtraBody()).isNotNull(); + assertThat(options.getExtraBody()).containsEntry("key1", "value1"); + assertThat(options.getExtraBody()).containsEntry("key2", "123"); + assertThat(options.getExtraBody()).containsKey("nested"); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfigurationIT.java index 38dd20e3a4..5d7fce6971 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfigurationIT.java @@ -30,7 +30,10 @@ import static org.assertj.core.api.Assertions.assertThat; /** + * Integration tests for {@link OpenAiEmbeddingAutoConfiguration}. + * * @author Sebastien Deleuze + * @author guan xu */ @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") public class OpenAiEmbeddingAutoConfigurationIT { @@ -114,4 +117,34 @@ public void embeddingOptionsTest() { }); } + @Test + void embeddingExtraBodyTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=http://TEST.BASE.URL", + + "spring.ai.openai.embedding.extra-body.key1=value1", + "spring.ai.openai.embedding.extra-body.key2=123", + "spring.ai.openai.embedding.extra-body.nested.key3=true" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(embeddingProperties.getExtraBody()).isNotNull(); + assertThat(embeddingProperties.getExtraBody()).containsEntry("key1", "value1"); + assertThat(embeddingProperties.getExtraBody()).containsEntry("key2", "123"); + assertThat(embeddingProperties.getExtraBody()).containsKey("nested"); + + var options = embeddingProperties.toOptions(); + assertThat(options.getExtraBody()).isNotNull(); + assertThat(options.getExtraBody()).containsEntry("key1", "value1"); + assertThat(options.getExtraBody()).containsEntry("key2", "123"); + assertThat(options.getExtraBody()).containsKey("nested"); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingPropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingPropertiesTests.java index 7cfd808044..3a6df424f7 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingPropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/test/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingPropertiesTests.java @@ -29,6 +29,7 @@ * * @author Christian Tzolov * @author Sebastien Deleuze + * @author guan xu */ public class OpenAiEmbeddingPropertiesTests { @@ -86,4 +87,34 @@ public void embeddingOptionsTest() { }); } + @Test + public void embeddingExtraBodyTest() { + + this.contextRunner + .withPropertyValues(// @formatter:off + "spring.ai.openai.api-key=API_KEY", + "spring.ai.openai.base-url=http://TEST.BASE.URL", + + "spring.ai.openai.embedding.extra-body.key1=value1", + "spring.ai.openai.embedding.extra-body.key2=123", + "spring.ai.openai.embedding.extra-body.nested.key3=true" + ) + // @formatter:on + .withConfiguration(AutoConfigurations.of(OpenAiEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class); + + assertThat(embeddingProperties.getExtraBody()).isNotNull(); + assertThat(embeddingProperties.getExtraBody()).containsEntry("key1", "value1"); + assertThat(embeddingProperties.getExtraBody()).containsEntry("key2", "123"); + assertThat(embeddingProperties.getExtraBody()).containsKey("nested"); + + var options = embeddingProperties.toOptions(); + assertThat(options.getExtraBody()).isNotNull(); + assertThat(options.getExtraBody()).containsEntry("key1", "value1"); + assertThat(options.getExtraBody()).containsEntry("key2", "123"); + assertThat(options.getExtraBody()).containsKey("nested"); + }); + } + } diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc index 14162ebc7b..cef81130b7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/openai-embeddings.adoc @@ -152,6 +152,7 @@ The prefix `spring.ai.openai.embedding` is property prefix that configures the ` | spring.ai.openai.embedding.encoding-format | The format to return the embeddings in. Can be either float or base64. | - | spring.ai.openai.embedding.user | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. | - | spring.ai.openai.embedding.dimensions | The number of dimensions the resulting output embeddings should have. Only supported in `text-embedding-3` and later models. | - +| spring.ai.openai.embedding.extra-body | Additional body properties to send to the OpenAI-compatible provider. | - |==== NOTE: You can override the common `spring.ai.openai.base-url` and `spring.ai.openai.api-key` for the `ChatModel` and `EmbeddingModel` implementations.