Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ public class OpenAiChatProperties extends AbstractOpenAiProperties {

private @Nullable String promptCacheKey;

private @Nullable Boolean replayReasoningContent;

private @Nullable Map<String, Object> extraBody;

public @Nullable String getModel() {
Expand Down Expand Up @@ -306,6 +308,14 @@ public void setPromptCacheKey(@Nullable String promptCacheKey) {
this.promptCacheKey = promptCacheKey;
}

public @Nullable Boolean getReplayReasoningContent() {
return this.replayReasoningContent;
}

public void setReplayReasoningContent(@Nullable Boolean replayReasoningContent) {
this.replayReasoningContent = replayReasoningContent;
}

public @Nullable Map<String, Object> getExtraBody() {
return this.extraBody;
}
Expand Down Expand Up @@ -343,6 +353,7 @@ public OpenAiChatOptions toOptions() {
.verbosity(this.verbosity)
.serviceTier(this.serviceTier)
.promptCacheKey(this.promptCacheKey)
.replayReasoningContent(this.replayReasoningContent)
.extraBody(this.extraBody)
.build();
}
Expand Down Expand Up @@ -776,6 +787,16 @@ public void setPromptCacheKey(@Nullable String promptCacheKey) {
OpenAiChatProperties.this.setPromptCacheKey(promptCacheKey);
}

@DeprecatedConfigurationProperty(replacement = "spring.ai.openai.chat.replay-reasoning-content")
@Deprecated(since = "2.0.0", forRemoval = true)
public @Nullable Boolean getReplayReasoningContent() {
return OpenAiChatProperties.this.getReplayReasoningContent();
}

public void setReplayReasoningContent(@Nullable Boolean replayReasoningContent) {
OpenAiChatProperties.this.setReplayReasoningContent(replayReasoningContent);
}

@DeprecatedConfigurationProperty(replacement = "spring.ai.openai.chat.extra-body")
@Deprecated(since = "2.0.0", forRemoval = true)
public @Nullable Map<String, Object> getExtraBody() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,47 @@ public void chatExtraBodyTest() {
});
}

@Test
public void chatReplayReasoningContentTest() {

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.replay-reasoning-content=false"
)
// @formatter:on
.withConfiguration(
AutoConfigurations.of(OpenAiChatAutoConfiguration.class, ToolCallingAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(OpenAiChatProperties.class);

assertThat(chatProperties.getReplayReasoningContent()).isFalse();

var options = chatProperties.toOptions();
assertThat(options.getReplayReasoningContent()).isFalse();
});
}

@Test
public void chatReplayReasoningContentDefaultsToReplaying() {

this.contextRunner
.withPropertyValues(// @formatter:off
"spring.ai.openai.api-key=API_KEY",
"spring.ai.openai.base-url=http://TEST.BASE.URL"
)
// @formatter:on
.withConfiguration(
AutoConfigurations.of(OpenAiChatAutoConfiguration.class, ToolCallingAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(OpenAiChatProperties.class);

// Absent the property the replay stays on, which is what DeepSeek's
// thinking mode requires.
assertThat(chatProperties.getReplayReasoningContent()).isNull();
assertThat(chatProperties.toOptions().getReplayReasoningContent()).isNull();
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,13 @@ private void verifyPromptChatOptions(Prompt prompt) {
*/
ChatCompletionCreateParams createRequest(Prompt prompt, boolean stream) {

// Replaying reasoning content is what OpenAI-compatible reasoning endpoints such
// as DeepSeek's thinking mode require, and what Groq rejects outright, so it can
// be turned off per request. Absent an explicit opt-out the content is replayed
// whenever present, which is the behavior plain OpenAI is unaffected by.
boolean replayReasoningContent = !(prompt.getOptions() instanceof OpenAiChatOptions chatOptions)
|| !Boolean.FALSE.equals(chatOptions.getReplayReasoningContent());

List<ChatCompletionMessageParam> chatCompletionMessageParams = prompt.getInstructions()
.stream()
.map(message -> {
Expand Down Expand Up @@ -671,10 +678,11 @@ else if (message.getMessageType() == MessageType.ASSISTANT) {
builder.toolCalls(toolCalls);
}

// Replay reasoning content only when present - plain OpenAI is
// unaffected
// Replay reasoning content only when present and not opted out -
// plain OpenAI is unaffected
Object reasoningContent = assistantMessage.getMetadata().get(REASONING_CONTENT);
if (reasoningContent instanceof String reasoning && StringUtils.hasText(reasoning)) {
if (replayReasoningContent && reasoningContent instanceof String reasoning
&& StringUtils.hasText(reasoning)) {
// "reasoning_content" is the wire field; REASONING_CONTENT is the
// metadata key
builder.putAdditionalProperty("reasoning_content", JsonValue.from(reasoning));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ public class OpenAiChatOptions implements ToolCallingChatOptions, StructuredOutp

private final @Nullable String promptCacheKey;

/**
* Whether to send a previous assistant message's reasoning content back as the
* {@code reasoning_content} property of the next request. When {@code null}, the
* content is replayed whenever it is present, which OpenAI-compatible reasoning
* endpoints such as DeepSeek's thinking mode require. Set to {@code false} for
* endpoints that reject the property, such as Groq.
*/
private final @Nullable Boolean replayReasoningContent;

/**
* 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
Expand Down Expand Up @@ -208,7 +217,7 @@ protected OpenAiChatOptions(@Nullable String baseUrl, @Nullable String apiKey, @
@Nullable String user, @Nullable Boolean parallelToolCalls, @Nullable Boolean store,
@Nullable Boolean strict, @Nullable Map<String, String> metadata, @Nullable String reasoningEffort,
@Nullable String verbosity, @Nullable String serviceTier, @Nullable String promptCacheKey,
@Nullable Map<String, Object> extraBody) {
@Nullable Boolean replayReasoningContent, @Nullable Map<String, Object> extraBody) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.credential = credential;
Expand Down Expand Up @@ -253,6 +262,7 @@ protected OpenAiChatOptions(@Nullable String baseUrl, @Nullable String apiKey, @
this.verbosity = verbosity;
this.serviceTier = serviceTier;
this.promptCacheKey = promptCacheKey;
this.replayReasoningContent = replayReasoningContent;
this.extraBody = (extraBody != null ? Map.copyOf(extraBody) : null);
}

Expand Down Expand Up @@ -526,6 +536,17 @@ public int getMaxRetries() {
return this.promptCacheKey;
}

/**
* Gets whether a previous assistant message's reasoning content is sent back as the
* {@code reasoning_content} property of the next request.
* @return {@code false} if the reasoning content is never replayed, {@code null} if
* it is replayed whenever present
* @since 2.0.2
*/
public @Nullable Boolean getReplayReasoningContent() {
return this.replayReasoningContent;
}

public @Nullable Map<String, Object> getExtraBody() {
return this.extraBody;
}
Expand Down Expand Up @@ -603,6 +624,7 @@ public Builder mutate() {
.verbosity(this.verbosity)
.serviceTier(this.serviceTier)
.promptCacheKey(this.promptCacheKey)
.replayReasoningContent(this.replayReasoningContent)
.extraBody(this.extraBody);
}

Expand Down Expand Up @@ -636,6 +658,7 @@ public boolean equals(@Nullable Object o) {
&& Objects.equals(this.verbosity, options.verbosity)
&& Objects.equals(this.serviceTier, options.serviceTier)
&& Objects.equals(this.promptCacheKey, options.promptCacheKey)
&& Objects.equals(this.replayReasoningContent, options.replayReasoningContent)
&& Objects.equals(this.extraBody, options.extraBody)
&& Objects.equals(this.toolCallbacks, options.toolCallbacks)
&& Objects.equals(this.toolContext, options.toolContext);
Expand All @@ -647,8 +670,8 @@ public int hashCode() {
this.maxTokens, this.maxCompletionTokens, this.n, this.outputModalities, this.outputAudio,
this.presencePenalty, this.responseFormat, this.streamOptions, this.seed, this.stop, this.temperature,
this.topP, this.toolChoice, this.user, this.parallelToolCalls, this.store, this.strict, this.metadata,
this.reasoningEffort, this.verbosity, this.serviceTier, this.promptCacheKey, this.extraBody,
this.toolCallbacks, this.toolContext);
this.reasoningEffort, this.verbosity, this.serviceTier, this.promptCacheKey,
this.replayReasoningContent, this.extraBody, this.toolCallbacks, this.toolContext);
}

public record AudioParameters(@Nullable Voice voice, @Nullable AudioResponseFormat format) {
Expand Down Expand Up @@ -814,6 +837,8 @@ public B clone() {

protected @Nullable Boolean strict;

protected @Nullable Boolean replayReasoningContent;

protected @Nullable Map<String, String> metadata;

protected @Nullable String reasoningEffort;
Expand Down Expand Up @@ -1006,6 +1031,21 @@ public B parallelToolCalls(@Nullable Boolean parallelToolCalls) {
return self();
}

/**
* Sets whether a previous assistant message's reasoning content is sent back as
* the {@code reasoning_content} property of the next request. Defaults to
* replaying it whenever present, which OpenAI-compatible reasoning endpoints such
* as DeepSeek's thinking mode require. Set to {@code false} for endpoints that
* reject the property, such as Groq.
* @param replayReasoningContent whether to replay the reasoning content
* @return this builder
* @since 2.0.2
*/
public B replayReasoningContent(@Nullable Boolean replayReasoningContent) {
this.replayReasoningContent = replayReasoningContent;
return self();
}

public B store(@Nullable Boolean store) {
this.store = store;
return self();
Expand Down Expand Up @@ -1153,6 +1193,9 @@ public B combineWith(ChatOptions.Builder<?> other) {
if (that.strict != null) {
this.strict = that.strict;
}
if (that.replayReasoningContent != null) {
this.replayReasoningContent = that.replayReasoningContent;
}
if (that.metadata != null) {
if (this.metadata == null) {
this.metadata = new HashMap<>(that.metadata);
Expand Down Expand Up @@ -1221,7 +1264,7 @@ public OpenAiChatOptions build() {
this.topLogprobs, this.maxCompletionTokens, this.n, this.outputModalities, this.outputAudio,
this.responseFormat, this.streamOptions, this.seed, this.toolChoice, this.user,
this.parallelToolCalls, this.store, this.strict, this.metadata, this.reasoningEffort,
this.verbosity, this.serviceTier, this.promptCacheKey, this.extraBody);
this.verbosity, this.serviceTier, this.promptCacheKey, this.replayReasoningContent, this.extraBody);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,53 @@ void reasoningContentReplayedWhenPresentInAssistantHistory() {
JsonValue.from("25 * 4 = 100."));
}

@Test
void reasoningContentNotReplayedWhenOptedOut() {
// Groq returns reasoning_content on the response but rejects the property on a
// subsequent request, so providers in that position opt out of the replay.
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model("test-model")
.replayReasoningContent(false)
.build();
OpenAiChatModel chatModel = OpenAiChatModel.builder()
.openAiClient(this.openAiClient)
.openAiClientAsync(this.openAiClientAsync)
.options(options)
.build();

AssistantMessage assistantMessage = AssistantMessage.builder()
.content("100")
.properties(Map.of("reasoningContent", "25 * 4 = 100."))
.build();
Prompt prompt = new Prompt(
List.of(new UserMessage("What's 25 * 4?"), assistantMessage, new UserMessage("Now divide that by 5")),
options);

ChatCompletionCreateParams request = chatModel.createRequest(prompt, false);

ChatCompletionAssistantMessageParam assistantParam = request.messages()
.stream()
.filter(ChatCompletionMessageParam::isAssistant)
.map(ChatCompletionMessageParam::asAssistant)
.findFirst()
.orElseThrow();
assertThat(assistantParam._additionalProperties()).doesNotContainKey("reasoning_content");
// Only the replay is dropped; the assistant turn itself still goes out.
assertThat(assistantParam.content().orElseThrow().text()).hasValue("100");
}

@Test
void replayReasoningContentIsCarriedOverByTheBuilder() {
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model("test-model")
.replayReasoningContent(false)
.build();

assertThat(options.mutate().build().getReplayReasoningContent()).isFalse();
assertThat(OpenAiChatOptions.builder().combineWith(options.mutate()).build().getReplayReasoningContent())
.isFalse();
}

@ParameterizedTest
@ValueSource(strings = { "reasoning_content", "reasoning" })
void streamingReasoningContentSurvivesAggregationWithToolCalls(String reasoningKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ The `JSON_SCHEMA` type enables link:https://platform.openai.com/docs/guides/stru
| spring.ai.openai.chat.tool-callbacks | Tool Callbacks to register with the ChatModel. | -
| spring.ai.openai.chat.service-tier | Specifies the link:https://platform.openai.com/docs/api-reference/responses/create#responses_create-service_tier[processing type] used for serving the request. | -
| spring.ai.openai.chat.extra-body | Additional parameters to include in the request. Accepts any key-value pairs that are flattened to the top level of the JSON request. Intended for use with OpenAI-compatible servers (vLLM, Ollama, etc.) that support parameters beyond the standard OpenAI API. The official OpenAI API rejects unknown parameters with a 400 error. See <<openai-compatible-servers>> for details. | -
| spring.ai.openai.chat.replay-reasoning-content | Whether a previous assistant message's reasoning content is sent back as the `reasoning_content` property of the next request. OpenAI-compatible reasoning endpoints such as DeepSeek's thinking mode require the property and reject a request without it, while others such as Groq reject the property itself. Set to `false` for the latter. | true
|====

[NOTE]
Expand Down