From c9db89f1e6d28e0c100d806ce105a93352e1a907 Mon Sep 17 00:00:00 2001 From: chengwudi1 Date: Mon, 14 Sep 2026 11:36:40 +0800 Subject: [PATCH] Make replaying reasoning content opt-out reasoning_content is echoed back on an assistant message so that OpenAI-compatible reasoning endpoints which require it keep working: DeepSeek's thinking mode returns HTTP 400 when an assistant turn that carried reasoning arrives without it. Other endpoints return the property but reject it on the way back, so Groq answers a follow-up request built from such a history with 400: 'messages.6' : for 'role:assistant' ... property 'reasoning_content' is unsupported Both cannot be satisfied at once, so the replay becomes configurable: OpenAiChatOptions.replayReasoningContent defaults to null, which keeps sending the property whenever it is present, and a value of false stops it. The property is also exposed as spring.ai.openai.chat.replay-reasoning-content. Closes #6968 Signed-off-by: chengwudi1 --- .../autoconfigure/OpenAiChatProperties.java | 21 ++++++++ .../OpenAiChatPropertiesTests.java | 43 ++++++++++++++++ .../ai/openai/OpenAiChatModel.java | 14 +++-- .../ai/openai/OpenAiChatOptions.java | 51 +++++++++++++++++-- .../ai/openai/OpenAiChatModelTests.java | 47 +++++++++++++++++ .../ROOT/pages/api/chat/openai-chat.adoc | 1 + 6 files changed, 170 insertions(+), 7 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java index 5dd495773b..1e7e92d956 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java @@ -96,6 +96,8 @@ public class OpenAiChatProperties extends AbstractOpenAiProperties { private @Nullable String promptCacheKey; + private @Nullable Boolean replayReasoningContent; + private @Nullable Map extraBody; public @Nullable String getModel() { @@ -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 getExtraBody() { return this.extraBody; } @@ -343,6 +353,7 @@ public OpenAiChatOptions toOptions() { .verbosity(this.verbosity) .serviceTier(this.serviceTier) .promptCacheKey(this.promptCacheKey) + .replayReasoningContent(this.replayReasoningContent) .extraBody(this.extraBody) .build(); } @@ -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 getExtraBody() { 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 e59b4d7782..438bc43f88 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 @@ -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(); + }); + } + } 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 4f5be79af6..7abe00378d 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 @@ -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 chatCompletionMessageParams = prompt.getInstructions() .stream() .map(message -> { @@ -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)); diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index be9b468c9b..a131f4e9b2 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -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 @@ -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 metadata, @Nullable String reasoningEffort, @Nullable String verbosity, @Nullable String serviceTier, @Nullable String promptCacheKey, - @Nullable Map extraBody) { + @Nullable Boolean replayReasoningContent, @Nullable Map extraBody) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.credential = credential; @@ -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); } @@ -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 getExtraBody() { return this.extraBody; } @@ -603,6 +624,7 @@ public Builder mutate() { .verbosity(this.verbosity) .serviceTier(this.serviceTier) .promptCacheKey(this.promptCacheKey) + .replayReasoningContent(this.replayReasoningContent) .extraBody(this.extraBody); } @@ -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); @@ -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) { @@ -814,6 +837,8 @@ public B clone() { protected @Nullable Boolean strict; + protected @Nullable Boolean replayReasoningContent; + protected @Nullable Map metadata; protected @Nullable String reasoningEffort; @@ -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(); @@ -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); @@ -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); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiChatModelTests.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiChatModelTests.java index 23c8141f0f..0cdb1cf466 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiChatModelTests.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiChatModelTests.java @@ -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) { diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index c13dd46238..1ed02b6bff 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -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 <> 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]