Skip to content

Commit ec717c1

Browse files
committed
OpenAiApi Add support for extraBody and reasoningContent
Squashed 17 commits from PR #4309 Signed-off-by: Alexandros Pappas <apappascs@gmail.com> Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf> Signed-off-by: Ilayaperumal Gopinathan <ilayaperumal.gopinathan@broadcom.com> Signed-off-by: Mark Pollack <mark.pollack@broadcom.com> Signed-off-by: SenreySong <25841017+SenreySong@users.noreply.github.com> Signed-off-by: Senrey_Song <25841017+SenreySong@users.noreply.github.com> Signed-off-by: Łukasz Jernaś <lukasz.jernas@allegro.com> Refactor extraBody to use @JsonAnyGetter for flat JSON serialization Replace manual JSON merging with Jackson @JsonAnyGetter annotation to flatten extraBody parameters to top-level JSON, matching OpenAI SDK behavior for compatibility with vLLM, Ollama, and other OpenAI-compatible servers. - Add @JsonAnyGetter to extraBody() accessor method - Remove ObjectMapper instance field and createDynamicRequestBody() method - Simplify WebClient calls to use .bodyValue(chatRequest) directly - Add ExtraBodySerializationTest to verify correct flat serialization - Fix unrelated OpenAiSpeechModelIT compilation error
1 parent 5783104 commit ec717c1

File tree

11 files changed

+193
-36
lines changed

11 files changed

+193
-36
lines changed

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatModel.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ public Flux<ChatResponse> internalStream(Prompt prompt, ChatResponse previousCha
319319
"index", choice.index() != null ? choice.index() : 0,
320320
"finishReason", getFinishReasonJson(choice.finishReason()),
321321
"refusal", StringUtils.hasText(choice.message().refusal()) ? choice.message().refusal() : "",
322-
"annotations", choice.message().annotations() != null ? choice.message().annotations() : List.of());
322+
"annotations", choice.message().annotations() != null ? choice.message().annotations() : List.of(),
323+
"reasoningContent", choice.message().reasoningContent() != null ? choice.message().reasoningContent() : "");
323324
return buildGeneration(choice, metadata, request);
324325
}).toList();
325326
// @formatter:on
@@ -606,7 +607,7 @@ else if (message.getMessageType() == MessageType.ASSISTANT) {
606607

607608
}
608609
return List.of(new ChatCompletionMessage(assistantMessage.getText(),
609-
ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls, null, audioOutput, null));
610+
ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls, null, audioOutput, null, null));
610611
}
611612
else if (message.getMessageType() == MessageType.TOOL) {
612613
ToolResponseMessage toolMessage = (ToolResponseMessage) message;
@@ -616,7 +617,7 @@ else if (message.getMessageType() == MessageType.TOOL) {
616617
return toolMessage.getResponses()
617618
.stream()
618619
.map(tr -> new ChatCompletionMessage(tr.responseData(), ChatCompletionMessage.Role.TOOL, tr.name(),
619-
tr.id(), null, null, null, null))
620+
tr.id(), null, null, null, null, null))
620621
.toList();
621622
}
622623
else {

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
137137
* modalities: ["audio"]
138138
* Note: that the audio modality is only available for the gpt-4o-audio-preview model
139139
* and is not supported for streaming completions.
140-
140+
*
141141
*/
142142
private @JsonProperty("audio") AudioParameters outputAudio;
143143

@@ -264,6 +264,8 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
264264
@JsonIgnore
265265
private Map<String, Object> toolContext = new HashMap<>();
266266

267+
private @JsonProperty("extra_body") Map<String, Object> extraBody;
268+
267269
// @formatter:on
268270

269271
public static Builder builder() {
@@ -306,6 +308,7 @@ public static OpenAiChatOptions fromOptions(OpenAiChatOptions fromOptions) {
306308
.webSearchOptions(fromOptions.getWebSearchOptions())
307309
.verbosity(fromOptions.getVerbosity())
308310
.serviceTier(fromOptions.getServiceTier())
311+
.extraBody(fromOptions.getExtraBody())
309312
.build();
310313
}
311314

@@ -502,6 +505,14 @@ public void setParallelToolCalls(Boolean parallelToolCalls) {
502505
this.parallelToolCalls = parallelToolCalls;
503506
}
504507

508+
public Map<String, Object> getExtraBody() {
509+
return this.extraBody;
510+
}
511+
512+
public void setExtraBody(Map<String, Object> extraBody) {
513+
this.extraBody = extraBody;
514+
}
515+
505516
@Override
506517
@JsonIgnore
507518
public List<ToolCallback> getToolCallbacks() {
@@ -630,7 +641,8 @@ public int hashCode() {
630641
this.streamOptions, this.seed, this.stop, this.temperature, this.topP, this.tools, this.toolChoice,
631642
this.user, this.parallelToolCalls, this.toolCallbacks, this.toolNames, this.httpHeaders,
632643
this.internalToolExecutionEnabled, this.toolContext, this.outputModalities, this.outputAudio,
633-
this.store, this.metadata, this.reasoningEffort, this.webSearchOptions, this.serviceTier);
644+
this.store, this.metadata, this.reasoningEffort, this.webSearchOptions, this.serviceTier,
645+
this.extraBody);
634646
}
635647

636648
@Override
@@ -665,7 +677,8 @@ public boolean equals(Object o) {
665677
&& Objects.equals(this.reasoningEffort, other.reasoningEffort)
666678
&& Objects.equals(this.webSearchOptions, other.webSearchOptions)
667679
&& Objects.equals(this.verbosity, other.verbosity)
668-
&& Objects.equals(this.serviceTier, other.serviceTier);
680+
&& Objects.equals(this.serviceTier, other.serviceTier)
681+
&& Objects.equals(this.extraBody, other.extraBody);
669682
}
670683

671684
@Override
@@ -933,6 +946,11 @@ public Builder serviceTier(OpenAiApi.ServiceTier serviceTier) {
933946
return this;
934947
}
935948

949+
public Builder extraBody(Map<String, Object> extraBody) {
950+
this.options.extraBody = extraBody;
951+
return this;
952+
}
953+
936954
public OpenAiChatOptions build() {
937955
return this.options;
938956
}

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.function.Predicate;
2626
import java.util.stream.Collectors;
2727

28+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
2829
import com.fasterxml.jackson.annotation.JsonFormat;
2930
import com.fasterxml.jackson.annotation.JsonIgnore;
3031
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@@ -246,7 +247,7 @@ public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chat
246247
headers.addAll(additionalHttpHeader);
247248
addDefaultHeadersIfMissing(headers);
248249
}) // @formatter:on
249-
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
250+
.bodyValue(chatRequest)
250251
.retrieve()
251252
.bodyToFlux(String.class)
252253
// cancels the flux stream after the "[DONE]" is received.
@@ -1129,7 +1130,8 @@ public record ChatCompletionRequest(// @formatter:off
11291130
@JsonProperty("user") String user,
11301131
@JsonProperty("reasoning_effort") String reasoningEffort,
11311132
@JsonProperty("web_search_options") WebSearchOptions webSearchOptions,
1132-
@JsonProperty("verbosity") String verbosity) {
1133+
@JsonProperty("verbosity") String verbosity,
1134+
Map<String, Object> extraBody) {
11331135

11341136
/**
11351137
* Shortcut constructor for a chat completion request with the given messages, model and temperature.
@@ -1141,7 +1143,7 @@ public record ChatCompletionRequest(// @formatter:off
11411143
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Double temperature) {
11421144
this(messages, model, null, null, null, null, null, null, null, null, null, null, null, null, null,
11431145
null, null, null, false, null, temperature, null,
1144-
null, null, null, null, null, null, null);
1146+
null, null, null, null, null, null, null, null);
11451147
}
11461148

11471149
/**
@@ -1155,7 +1157,7 @@ public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model,
11551157
this(messages, model, null, null, null, null, null, null,
11561158
null, null, null, List.of(OutputModality.AUDIO, OutputModality.TEXT), audio, null, null,
11571159
null, null, null, stream, null, null, null,
1158-
null, null, null, null, null, null, null);
1160+
null, null, null, null, null, null, null, null);
11591161
}
11601162

11611163
/**
@@ -1170,7 +1172,7 @@ public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model,
11701172
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Double temperature, boolean stream) {
11711173
this(messages, model, null, null, null, null, null, null, null, null, null,
11721174
null, null, null, null, null, null, null, stream, null, temperature, null,
1173-
null, null, null, null, null, null, null);
1175+
null, null, null, null, null, null, null, null);
11741176
}
11751177

11761178
/**
@@ -1186,7 +1188,7 @@ public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model,
11861188
List<FunctionTool> tools, Object toolChoice) {
11871189
this(messages, model, null, null, null, null, null, null, null, null, null,
11881190
null, null, null, null, null, null, null, false, null, 0.8, null,
1189-
tools, toolChoice, null, null, null, null, null);
1191+
tools, toolChoice, null, null, null, null, null, null);
11901192
}
11911193

11921194
/**
@@ -1197,9 +1199,9 @@ public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model,
11971199
* as they become available, with the stream terminated by a data: [DONE] message.
11981200
*/
11991201
public ChatCompletionRequest(List<ChatCompletionMessage> messages, Boolean stream) {
1200-
this(messages, null, null, null, null, null, null, null, null, null, null,
1201-
null, null, null, null, null, null, null, stream, null, null, null,
1202-
null, null, null, null, null, null, null);
1202+
this(messages, null, null, null, null, null, null, null, null, null, null, null, null, null,
1203+
null, null, null, null, stream, null, null, null, null, null, null, null, null, null,
1204+
null, null);
12031205
}
12041206

12051207
/**
@@ -1210,9 +1212,20 @@ public ChatCompletionRequest(List<ChatCompletionMessage> messages, Boolean strea
12101212
*/
12111213
public ChatCompletionRequest streamOptions(StreamOptions streamOptions) {
12121214
return new ChatCompletionRequest(this.messages, this.model, this.store, this.metadata, this.frequencyPenalty, this.logitBias, this.logprobs,
1213-
this.topLogprobs, this.maxTokens, this.maxCompletionTokens, this.n, this.outputModalities, this.audioParameters, this.presencePenalty,
1214-
this.responseFormat, this.seed, this.serviceTier, this.stop, this.stream, streamOptions, this.temperature, this.topP,
1215-
this.tools, this.toolChoice, this.parallelToolCalls, this.user, this.reasoningEffort, this.webSearchOptions, this.verbosity);
1215+
this.topLogprobs, this.maxTokens, this.maxCompletionTokens, this.n, this.outputModalities, this.audioParameters, this.presencePenalty,
1216+
this.responseFormat, this.seed, this.serviceTier, this.stop, this.stream, streamOptions, this.temperature, this.topP,
1217+
this.tools, this.toolChoice, this.parallelToolCalls, this.user, this.reasoningEffort, this.webSearchOptions, this.verbosity, this.extraBody);
1218+
}
1219+
1220+
/**
1221+
* Overrides the default accessor to add @JsonAnyGetter annotation.
1222+
* This causes Jackson to flatten the extraBody map contents to the top level of the JSON,
1223+
* matching the behavior expected by OpenAI-compatible servers like vLLM, Ollama, etc.
1224+
* @return The extraBody map, or null if not set.
1225+
*/
1226+
@JsonAnyGetter
1227+
public Map<String, Object> extraBody() {
1228+
return this.extraBody;
12161229
}
12171230

12181231
/**
@@ -1424,7 +1437,8 @@ public record ChatCompletionMessage(// @formatter:off
14241437
@JsonProperty("tool_calls") @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) List<ToolCall> toolCalls,
14251438
@JsonProperty("refusal") String refusal,
14261439
@JsonProperty("audio") AudioOutput audioOutput,
1427-
@JsonProperty("annotations") List<Annotation> annotations
1440+
@JsonProperty("annotations") List<Annotation> annotations,
1441+
@JsonProperty("reasoning_content") String reasoningContent
14281442
) { // @formatter:on
14291443

14301444
/**
@@ -1434,7 +1448,7 @@ public record ChatCompletionMessage(// @formatter:off
14341448
* @param role The role of the author of this message.
14351449
*/
14361450
public ChatCompletionMessage(Object content, Role role) {
1437-
this(content, role, null, null, null, null, null, null);
1451+
this(content, role, null, null, null, null, null, null, null);
14381452
}
14391453

14401454
/**

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiStreamFunctionCallingHelper.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
/**
3737
* Helper class to support Streaming function calling.
3838
*
39+
* <p>
3940
* It can merge the streamed ChatCompletionChunk in case of function calling message.
4041
*
4142
* @author Christian Tzolov
@@ -100,6 +101,8 @@ private ChunkChoice merge(ChunkChoice previous, ChunkChoice current) {
100101
private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) {
101102
String content = (current.content() != null ? current.content()
102103
: "" + ((previous.content() != null) ? previous.content() : ""));
104+
String reasoningContent = (current.reasoningContent() != null ? current.reasoningContent()
105+
: "" + ((previous.reasoningContent() != null) ? previous.reasoningContent() : ""));
103106
Role role = (current.role() != null ? current.role() : previous.role());
104107
role = (role != null ? role : Role.ASSISTANT); // default to ASSISTANT (if null
105108
String name = (current.name() != null ? current.name() : previous.name());
@@ -138,7 +141,8 @@ private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompleti
138141
toolCalls.add(lastPreviousTooCall);
139142
}
140143
}
141-
return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls, refusal, audioOutput, annotations);
144+
return new ChatCompletionMessage(content, role, name, toolCallId, toolCalls, refusal, audioOutput, annotations,
145+
reasoningContent);
142146
}
143147

144148
private ToolCall merge(ToolCall previous, ToolCall current) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.openai.api;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
29+
/**
30+
* Test to verify JSON serialization behavior of extraBody parameter. This test verifies
31+
* that @JsonAnyGetter correctly flattens extraBody fields to the top level of the JSON
32+
* request, matching the behavior expected by OpenAI-compatible servers like vLLM, Ollama,
33+
* and matching the pattern used by the official OpenAI SDK and LangChain4j.
34+
*/
35+
class ExtraBodySerializationTest {
36+
37+
private final ObjectMapper objectMapper = new ObjectMapper();
38+
39+
@Test
40+
void testExtraBodySerializationFlattensToTopLevel() throws Exception {
41+
// Arrange: Create request with extraBody containing vLLM/Ollama parameters
42+
ChatCompletionRequest request = new ChatCompletionRequest(List.of(), // messages
43+
"gpt-4", // model
44+
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false,
45+
null, null, null, null, null, null, null, null, null, null,
46+
Map.of("top_k", 50, "repetition_penalty", 1.1) // extraBody
47+
);
48+
49+
// Act: Serialize to JSON
50+
String json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(request);
51+
52+
// Debug: Print the actual JSON
53+
System.out.println("=== JSON Output (with @JsonAnyGetter) ===");
54+
System.out.println(json);
55+
56+
// Assert: Verify @JsonAnyGetter flattens fields to top level
57+
assertThat(json).contains("\"top_k\" : 50");
58+
assertThat(json).contains("\"repetition_penalty\" : 1.1");
59+
assertThat(json).doesNotContain("\"extra_body\"");
60+
61+
System.out.println("\n=== Analysis ===");
62+
System.out.println("✓ Fields are FLATTENED to top level (correct!)");
63+
System.out.println(" Format: { \"model\": \"gpt-4\", \"top_k\": 50, \"repetition_penalty\": 1.1 }");
64+
System.out.println(" This matches official OpenAI SDK and LangChain4j behavior");
65+
System.out.println(" This is CORRECT for vLLM, Ollama, and other OpenAI-compatible servers");
66+
}
67+
68+
@Test
69+
void testExtraBodyWithEmptyMap() throws Exception {
70+
// Arrange: Request with empty extraBody map
71+
ChatCompletionRequest request = new ChatCompletionRequest(List.of(), // messages
72+
"gpt-4", // model
73+
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false,
74+
null, null, null, null, null, null, null, null, null, null, Map.of() // empty
75+
// extraBody
76+
);
77+
78+
// Act
79+
String json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(request);
80+
81+
// Debug
82+
System.out.println("\n=== JSON Output (empty extraBody map) ===");
83+
System.out.println(json);
84+
85+
// Assert: No extra fields should appear
86+
assertThat(json).doesNotContain("extra_body");
87+
assertThat(json).doesNotContain("top_k");
88+
}
89+
90+
@Test
91+
void testExtraBodyNullSerialization() throws Exception {
92+
// Arrange: Request with null extraBody (normal OpenAI usage)
93+
ChatCompletionRequest request = new ChatCompletionRequest(List.of(), // messages
94+
"gpt-4", // model
95+
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false,
96+
null, null, null, null, null, null, null, null, null, null, null // extraBody
97+
// =
98+
// null
99+
);
100+
101+
// Act
102+
String json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(request);
103+
104+
// Debug
105+
System.out.println("\n=== JSON Output (null extraBody) ===");
106+
System.out.println(json);
107+
108+
// Assert: extra_body should not appear in JSON when null
109+
assertThat(json).doesNotContain("extra_body");
110+
assertThat(json).doesNotContain("top_k");
111+
}
112+
113+
}

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiBuilderTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ void dynamicApiKeyWebClient() throws InterruptedException {
477477
"role": "assistant",
478478
"content": "Hello world"
479479
},
480+
"reasoning_content": "test",
480481
"finish_reason": "stop"
481482
}
482483
],

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ void validateReasoningTokens() {
8181
ChatCompletionMessage.Role.USER);
8282
ChatCompletionRequest request = new ChatCompletionRequest(List.of(userMessage), "gpt-5", null, null, null, null,
8383
null, null, null, null, null, null, null, null, null, null, null, null, false, null, null, null, null,
84-
null, null, null, "high", null, null);
84+
null, null, null, "high", null, null, null);
8585
ResponseEntity<ChatCompletion> response = this.openAiApi.chatCompletionEntity(request);
8686

8787
assertThat(response).isNotNull();
@@ -185,7 +185,7 @@ void chatCompletionEntityWithNewModelsAndLowVerbosity(OpenAiApi.ChatModel modelN
185185

186186
ChatCompletionRequest request = new ChatCompletionRequest(List.of(chatCompletionMessage), // messages
187187
modelName.getValue(), null, null, null, null, null, null, null, null, null, null, null, null, null,
188-
null, null, null, false, null, 1.0, null, null, null, null, null, null, null, "low");
188+
null, null, null, false, null, 1.0, null, null, null, null, null, null, null, "low", null);
189189

190190
ResponseEntity<ChatCompletion> response = this.openAiApi.chatCompletionEntity(request);
191191

@@ -232,7 +232,7 @@ void chatCompletionEntityWithServiceTier(OpenAiApi.ServiceTier serviceTier) {
232232
ChatCompletionRequest request = new ChatCompletionRequest(List.of(chatCompletionMessage), // messages
233233
OpenAiApi.ChatModel.GPT_4_O.value, null, null, null, null, null, null, null, null, null, null, null,
234234
null, null, null, serviceTier.getValue(), null, false, null, 1.0, null, null, null, null, null, null,
235-
null, null);
235+
null, null, null);
236236

237237
ResponseEntity<ChatCompletion> response = this.openAiApi.chatCompletionEntity(request);
238238

0 commit comments

Comments
 (0)