diff --git a/orchestration/pom.xml b/orchestration/pom.xml index 8b8d7f0f1..f5cdfa976 100644 --- a/orchestration/pom.xml +++ b/orchestration/pom.xml @@ -36,10 +36,10 @@ ${project.basedir}/../ - 82% - 94% - 93% - 75% + 83% + 95% + 94% + 77% 95% 100% diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java index 26c6f472c..958f25e8c 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java @@ -6,31 +6,40 @@ import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; import com.sap.ai.sdk.orchestration.model.MessageToolCall; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.ai.sdk.orchestration.model.TextContent; import java.util.ArrayList; import java.util.List; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import lombok.AccessLevel; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.Value; import lombok.experimental.Accessors; import lombok.val; /** Represents a chat message as 'assistant' to the orchestration service. */ @Value -@Getter @Accessors(fluent = true) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class AssistantMessage implements Message { /** The role of the assistant. */ @Nonnull String role = "assistant"; /** The content of the message. */ - @Nonnull @Getter MessageContent content; + @Nonnull MessageContent content; /** Tool call if there is any. */ @Nullable List toolCalls; + /** Reasoning (thinking) blocks from the model's previous turn. */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Getter(AccessLevel.NONE) + @Nullable + List reasoningContent; + /** * Creates a new assistant message with the given single message. * @@ -39,6 +48,7 @@ public class AssistantMessage implements Message { public AssistantMessage(@Nonnull final String singleMessage) { content = new MessageContent(List.of(new TextItem(singleMessage))); toolCalls = null; + reasoningContent = null; } /** @@ -49,6 +59,7 @@ public AssistantMessage(@Nonnull final String singleMessage) { AssistantMessage(@Nonnull final MessageContent content) { this.content = content; toolCalls = null; + reasoningContent = null; } /** @@ -61,12 +72,7 @@ public AssistantMessage(@Nonnull final String singleMessage) { public AssistantMessage(@Nonnull final List toolCalls) { content = new MessageContent(List.of()); this.toolCalls = toolCalls; - } - - private AssistantMessage( - @Nonnull final MessageContent content, @Nullable final List toolCalls) { - this.content = content; - this.toolCalls = toolCalls; + reasoningContent = null; } /** @@ -81,7 +87,18 @@ private AssistantMessage( public AssistantMessage withToolCalls(@Nonnull final List toolCalls) { val newToolcalls = new ArrayList<>(this.toolCalls != null ? this.toolCalls : List.of()); newToolcalls.addAll(toolCalls); - return new AssistantMessage(this.content, newToolcalls); + return new AssistantMessage(this.content, newToolcalls, this.reasoningContent); + } + + /** + * Returns a new AssistantMessage instance carrying the given reasoning blocks. + * + * @param reasoningContent the reasoning blocks from the previous assistant turn. + * @return a new AssistantMessage instance with the reasoning content set. + */ + @Nonnull + AssistantMessage withReasoningContent(@Nonnull final List reasoningContent) { + return new AssistantMessage(this.content, this.toolCalls, List.copyOf(reasoningContent)); } @Nonnull @@ -93,6 +110,10 @@ public ChatMessage createChatMessage() { assistantChatMessage.setToolCalls(toolCalls); } + if (reasoningContent != null) { + assistantChatMessage.setReasoningContent(reasoningContent); + } + ChatMessageContent text; if (content.items().size() == 1 && content.items().get(0) instanceof TextItem textItem) { text = ChatMessageContent.create(textItem.text()); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java index 1345deaba..0f996e5ea 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java @@ -518,6 +518,19 @@ public OrchestrationAiModel withParam( return withParam(param.getName(), value); } + /** + * Set the {@code reasoning_effort} parameter on this model. + * + * @param effort the reasoning effort level. + * @return a new model with the {@code reasoning_effort} parameter set. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @Nonnull + public OrchestrationAiModel withReasoningEffort(@Nonnull final ReasoningEffort effort) { + return withParam(Parameter.REASONING_EFFORT, effort.getValue()); + } + /** * Parameter key for a model. * @@ -543,6 +556,9 @@ public interface Parameter { /** The number of chat completion choices to generate for each input message. */ Parameter N = () -> "n"; + /** The reasoning effort for reasoning-capable models. */ + Parameter REASONING_EFFORT = () -> "reasoning_effort"; + /** * The name of the parameter. * diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java index c9b2c882d..b074022ab 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java @@ -2,6 +2,8 @@ import com.sap.ai.sdk.core.common.StreamedDelta; import com.sap.ai.sdk.orchestration.model.CompletionPostResponseStreaming; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; +import java.util.List; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.val; @@ -27,6 +29,24 @@ public String getDeltaContent() { return ""; } + /** + * Get the reasoning (thinking) text carried by this chunk. + * + * @return the reasoning text of each block in this chunk; never {@code null}, may be empty. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @Nonnull + public List getDeltaReasoningContent() { + val choices = getFinalResult().getChoices(); + if (!choices.isEmpty() && choices.get(0).getIndex() == 0) { + return choices.get(0).getDelta().getReasoningContent().stream() + .map(ReasoningBlock::getContent) + .toList(); + } + return List.of(); + } + @Nullable @Override public String getFinishReason() { diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 629bf506a..6622d0806 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -10,6 +10,7 @@ import com.sap.ai.sdk.orchestration.model.ChatMessageContent; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.LLMChoice; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.ai.sdk.orchestration.model.SystemChatMessage; import com.sap.ai.sdk.orchestration.model.TokenUsage; import com.sap.ai.sdk.orchestration.model.ToolChatMessage; @@ -67,6 +68,30 @@ public TokenUsage getTokenUsage() { return originalResponse.getFinalResult().getUsage(); } + /** + * Get the reasoning (thinking) content produced by the model, as text, if any. + * + * @return the reasoning text of each block; never {@code null}, may be empty. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @Nonnull + public List getReasoningContent() { + return getChoice().getMessage().getReasoningContent().stream() + .map(ReasoningBlock::getContent) + .toList(); + } + + /** + * Get the reasoning (thinking) content as a single joined string, for display purposes. + * + * @return the concatenated reasoning text; empty if there is no reasoning. + */ + @Nonnull + public String getReasoningText() { + return String.join("", getReasoningContent()); + } + /** * Get all messages. This can be used for subsequent prompts as a message history. * @@ -80,13 +105,19 @@ public List getAllMessages() throws IllegalArgumentException { originalResponse.getIntermediateResults().getTemplating()) { if (chatMessage instanceof AssistantChatMessage assistantChatMessage) { val toolCalls = assistantChatMessage.getToolCalls(); + AssistantMessage assistantMessage; if (!toolCalls.isEmpty()) { - messages.add(new AssistantMessage(toolCalls)); + assistantMessage = new AssistantMessage(toolCalls); } else { - messages.add( + assistantMessage = new AssistantMessage( - MessageContent.fromChatMessageContent(assistantChatMessage.getContent()))); + MessageContent.fromChatMessageContent(assistantChatMessage.getContent())); + } + final var reasoning = assistantChatMessage.getReasoningContent(); + if (!reasoning.isEmpty()) { + assistantMessage = assistantMessage.withReasoningContent(reasoning); } + messages.add(assistantMessage); } else if (chatMessage instanceof SystemChatMessage systemChatMessage) { messages.add( new SystemMessage( @@ -106,7 +137,12 @@ public List getAllMessages() throws IllegalArgumentException { } } - messages.add(Message.assistant(getChoice().getMessage().getContent())); + var lastAssistant = Message.assistant(getChoice().getMessage().getContent()); + final var lastReasoning = getChoice().getMessage().getReasoningContent(); + if (!lastReasoning.isEmpty()) { + lastAssistant = lastAssistant.withReasoningContent(lastReasoning); + } + messages.add(lastAssistant); return messages; } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java new file mode 100644 index 000000000..ad94e5f1a --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java @@ -0,0 +1,56 @@ +package com.sap.ai.sdk.orchestration; + +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; + +/** + * Accumulates reasoning ({@code reasoning_content}) text across the chunks of a streaming + * orchestration response, merging the pieces of each block by their position. + * + * @see SAP AI Core: + * Orchestration - Reasoning + */ +public class ReasoningAccumulator { + + private final List contentByIndex = new ArrayList<>(); + + /** + * Consume the reasoning content carried by a single stream chunk. + * + * @param delta the streaming delta. + */ + public void accept(@Nonnull final OrchestrationChatCompletionDelta delta) { + final List chunk = rawReasoning(delta); + for (int i = 0; i < chunk.size(); i++) { + while (contentByIndex.size() <= i) { + contentByIndex.add(new StringBuilder()); + } + final String content = chunk.get(i).getContent(); + if (!content.isEmpty()) { + contentByIndex.get(i).append(content); + } + } + } + + /** + * Assemble the fully accumulated reasoning text, one entry per reasoning block. + * + * @return the assembled reasoning text, in the order it appeared in the stream. + */ + @Nonnull + public List assemble() { + return contentByIndex.stream().map(StringBuilder::toString).toList(); + } + + @Nonnull + private static List rawReasoning( + @Nonnull final OrchestrationChatCompletionDelta delta) { + final var choices = delta.getFinalResult().getChoices(); + if (!choices.isEmpty() && choices.get(0).getIndex() == 0) { + return choices.get(0).getDelta().getReasoningContent(); + } + return List.of(); + } +} diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java new file mode 100644 index 000000000..735118c00 --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java @@ -0,0 +1,41 @@ +package com.sap.ai.sdk.orchestration; + +import javax.annotation.Nonnull; + +/** + * Reasoning effort levels for the harmonized {@code reasoning_effort} model parameter. + * + *

Not all reasoning-capable models support the full range of values. Where a model does not + * support a given effort, orchestration transparently maps it to the closest equivalent. + * + * @see SAP AI Core: + * Orchestration - Reasoning + */ +public enum ReasoningEffort { + /** Minimal reasoning effort. */ + MINIMAL("minimal"), + /** Low reasoning effort. */ + LOW("low"), + /** Medium reasoning effort. */ + MEDIUM("medium"), + /** High reasoning effort. */ + HIGH("high"), + /** Disable reasoning when the model supports doing so. */ + NONE("none"); + + private final String value; + + ReasoningEffort(@Nonnull final String value) { + this.value = value; + } + + /** + * Wire string sent as the value of the {@code reasoning_effort} model parameter. + * + * @return the wire value. + */ + @Nonnull + public String getValue() { + return value; + } +} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java new file mode 100644 index 000000000..d912d8124 --- /dev/null +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java @@ -0,0 +1,190 @@ +package com.sap.ai.sdk.orchestration; + +import static com.sap.ai.sdk.orchestration.OrchestrationJacksonConfiguration.getOrchestrationObjectMapper; +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.ai.sdk.orchestration.model.AssistantChatMessage; +import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; +import com.sap.ai.sdk.orchestration.model.CompletionPostResponseStreaming; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; +import java.util.List; +import javax.annotation.Nonnull; +import lombok.val; +import org.junit.jupiter.api.Test; + +/** Unit tests for the reasoning-content SDK surface */ +class OrchestrationReasoningContentTest { + + private static final String SYNC_RESPONSE_TEMPLATE = + """ + { + "intermediate_results": {"templating": []}, + "final_result": { + "choices": [ + { + "message": {"role": "assistant", "content": "The answer is 42."%s} + } + ] + } + } + """; + + private static final String STREAM_CHUNK_TEMPLATE = + """ + { + "final_result": { + "choices": [ + { + "index": %d, + "delta": {"role": "assistant", "content": "hello"%s} + } + ] + } + } + """; + + @Test + void withReasoningEffortSetsParam() { + val model = OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM); + + assertThat(model.getParams()).containsEntry("reasoning_effort", "medium"); + assertThat(model.createConfig().getParams()).containsEntry("reasoning_effort", "medium"); + } + + @Test + void assistantMessageSerializesReasoningContentVerbatim() { + // Reasoning content (including the opaque signatures) is attached internally by the SDK and + // must be serialized verbatim so it can be re-submitted in messages_history. + val blocks = + List.of( + ReasoningBlock.create().content("Step 1: think").signature("sig-1"), + ReasoningBlock.create().content("Step 2: conclude").signature("sig-2")); + + val message = new AssistantMessage("The answer is 42.").withReasoningContent(blocks); + + val serialized = (AssistantChatMessage) message.createChatMessage(); + assertThat(serialized.getReasoningContent()).hasSize(2); + assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("Step 1: think"); + assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("sig-1"); + assertThat(serialized.getReasoningContent().get(1).getContent()).isEqualTo("Step 2: conclude"); + assertThat(serialized.getReasoningContent().get(1).getSignature()).isEqualTo("sig-2"); + } + + @Test + void chatResponseGetReasoningContentReturnsEmptyWhenAbsent() throws Exception { + val response = parseSyncResponse(""); + + assertThat(response.getReasoningContent()).isEmpty(); + assertThat(response.getReasoningText()).isEmpty(); + } + + @Test + void chatResponseGetReasoningContentReturnsText() throws Exception { + val response = + parseSyncResponse( + ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]"); + + assertThat(response.getReasoningContent()).containsExactly("thinking..."); + assertThat(response.getReasoningText()).isEqualTo("thinking..."); + } + + @Test + void getAllMessagesReSubmitsReasoningVerbatim() throws Exception { + // The final assistant message reconstructed by getAllMessages() must serialize the reasoning + // content (content + opaque signature) exactly as returned, ready for the next request. + val response = + parseSyncResponse( + ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]"); + + val last = response.getLastMessage().createChatMessage(); + val serialized = (AssistantChatMessage) last; + assertThat(serialized.getReasoningContent()).hasSize(1); + assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("thinking..."); + assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("sig-a"); + } + + @Test + void getAllMessagesReSubmitsHistoryAssistantReasoningVerbatim() throws Exception { + val json = + new String( + java.util.Objects.requireNonNull( + getClass() + .getClassLoader() + .getResourceAsStream("reasoningHistoryResponse.json")) + .readAllBytes()); + val parsed = getOrchestrationObjectMapper().readValue(json, CompletionPostResponse.class); + val response = new OrchestrationChatResponse(parsed); + + val messages = response.getAllMessages(); + // messages[0] user, messages[1] history assistant with reasoning, messages[2] final assistant + val serialized = (AssistantChatMessage) messages.get(1).createChatMessage(); + assertThat(serialized.getReasoningContent()).hasSize(1); + assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("past-thought"); + assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("past-sig"); + } + + @Test + void reasoningAccumulatorMergesTextByIndex() throws Exception { + val chunk1 = + parseStreamingDelta( + 0, + ", \"reasoning_content\": [" + + "{\"content\": \"fir\", \"signature\": \"\"}," + + "{\"content\": \"sec\", \"signature\": \"\"}]"); + val chunk2 = + parseStreamingDelta( + 0, + ", \"reasoning_content\": [" + + "{\"content\": \"st\", \"signature\": \"\"}," + + "{\"content\": \"ond\", \"signature\": \"\"}]"); + + val accumulator = new ReasoningAccumulator(); + accumulator.accept(chunk1); + accumulator.accept(chunk2); + + assertThat(accumulator.assemble()).containsExactly("first", "second"); + } + + @Test + void streamingDeltaExposesReasoningText() throws Exception { + val delta = + parseStreamingDelta( + 0, ", \"reasoning_content\": [{\"content\": \"chunk\", \"signature\": \"\"}]"); + + assertThat(delta.getDeltaContent()).isEqualTo("hello"); + assertThat(delta.getDeltaReasoningContent()).containsExactly("chunk"); + } + + @Test + void streamingDeltaReturnsEmptyForNonZeroIndex() throws Exception { + val delta = + parseStreamingDelta( + 1, ", \"reasoning_content\": [{\"content\": \"x\", \"signature\": \"\"}]"); + + assertThat(delta.getDeltaContent()).isEmpty(); + assertThat(delta.getDeltaReasoningContent()).isEmpty(); + + // A non-zero-index delta contributes nothing to the accumulator either. + val accumulator = new ReasoningAccumulator(); + accumulator.accept(delta); + assertThat(accumulator.assemble()).isEmpty(); + } + + private static @Nonnull OrchestrationChatResponse parseSyncResponse( + final String reasoningJsonFragment) throws Exception { + val json = String.format(SYNC_RESPONSE_TEMPLATE, reasoningJsonFragment); + val parsed = getOrchestrationObjectMapper().readValue(json, CompletionPostResponse.class); + return new OrchestrationChatResponse(parsed); + } + + private static @Nonnull OrchestrationChatCompletionDelta parseStreamingDelta( + final int index, final String reasoningJsonFragment) throws Exception { + val json = String.format(STREAM_CHUNK_TEMPLATE, index, reasoningJsonFragment); + val parsed = + getOrchestrationObjectMapper().readValue(json, CompletionPostResponseStreaming.class); + val delta = new OrchestrationChatCompletionDelta(); + delta.setRequestId(parsed.getRequestId()); + delta.setFinalResult(parsed.getFinalResult()); + return delta; + } +} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java index 478dfc3f1..376938dde 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java @@ -81,6 +81,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -1734,4 +1735,106 @@ void testFallbackModules() throws IOException { verify( postRequestedFor(urlPathEqualTo("/v2/completion")).withRequestBody(equalToJson(request))); } + + @Test + void streamReasoning() throws IOException { + final var body = + new String( + Objects.requireNonNull( + getClass().getClassLoader().getResourceAsStream("streamReasoning.txt")) + .readAllBytes()); + stubFor( + post(anyUrl()) + .willReturn(aResponse().withBody(body).withHeader("Content-Type", "application/json"))); + + final var reasoningConfig = + new OrchestrationModuleConfig() + .withLlmConfig( + OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + final var request = OrchestrationClient.toCompletionPostRequest(prompt, reasoningConfig); + + final var answerChunks = new ArrayList(); + final var accumulator = new ReasoningAccumulator(); + try (var stream = client.streamChatCompletionDeltas(request)) { + stream.forEach( + delta -> { + if (!delta.getDeltaContent().isEmpty()) { + answerChunks.add(delta.getDeltaContent()); + } + accumulator.accept(delta); + }); + } + final var reasoning = accumulator.assemble(); + + assertThat(answerChunks).containsExactly("42"); + assertThat(reasoning).containsExactly("Let me think."); + } + + @Test + void reasoningResponse() { + stubFor( + post(urlPathEqualTo("/v2/completion")) + .willReturn( + aResponse() + .withBodyFile("reasoningResponse.json") + .withHeader("Content-Type", "application/json"))); + + final var reasoningConfig = + new OrchestrationModuleConfig() + .withLlmConfig( + OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + final var reasoningPrompt = + new OrchestrationPrompt("Think carefully and explain step by step: Why is the sky blue?"); + + final var response = client.chatCompletion(reasoningPrompt, reasoningConfig); + + assertThat(response.getContent()).startsWith("# Why the Sky is Blue"); + assertThat(response.getReasoningContent()).hasSize(1); + assertThat(response.getReasoningContent().get(0)) + .startsWith("This is a classic question about atmospheric physics."); + assertThat(response.getReasoningText()).isEqualTo(response.getReasoningContent().get(0)); + + // getAllMessages() must attach the reasoning content (including the opaque signature) to the + // final assistant message so it round-trips in messages_history. + final var serialized = + (com.sap.ai.sdk.orchestration.model.AssistantChatMessage) + response.getLastMessage().createChatMessage(); + assertThat(serialized.getReasoningContent()).hasSize(1); + assertThat(serialized.getReasoningContent().get(0).getSignature()) + .startsWith("EtAECnAIDxABGAIq"); + } + + @Test + void multiTurnReasoningPreservesReasoningContent() { + // Turn 1: model returns reasoning content + signature. + stubFor( + post(urlPathEqualTo("/v2/completion")) + .willReturn( + aResponse() + .withBodyFile("multiTurnReasoningTurn1Response.json") + .withHeader("Content-Type", "application/json"))); + + final var reasoningConfig = + new OrchestrationModuleConfig() + .withLlmConfig( + OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + final var firstResponse = + client.chatCompletion(new OrchestrationPrompt("What is 6 times 7?"), reasoningConfig); + + // Turn 1 assertions: reasoning text parsed correctly. + assertThat(firstResponse.getContent()).isEqualTo("6 times 7 is 42."); + assertThat(firstResponse.getReasoningContent()).hasSize(1); + + // Turn 2: send a follow-up carrying the whole history (which includes turn 1's reasoning). + final var followUp = + new OrchestrationPrompt("Are you sure?").messageHistory(firstResponse.getAllMessages()); + client.chatCompletion(followUp, reasoningConfig); + + // The outgoing request for turn 2 must contain the reasoning_content from turn 1 (content + + // signature) in the messages_history array. This is the multi-turn round-trip contract. + final String expectedTurn2Request = fileLoaderStr.apply("multiTurnReasoningTurn2Request.json"); + verify( + postRequestedFor(urlPathEqualTo("/v2/completion")) + .withRequestBody(equalToJson(expectedTurn2Request, true, true))); + } } diff --git a/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json b/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json new file mode 100644 index 000000000..0b82c1462 --- /dev/null +++ b/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json @@ -0,0 +1,54 @@ +{ + "request_id": "46f4b2ea-8fd1-9611-84ee-9b072ebe2aae", + "intermediate_results": { + "templating": [ + {"content": "What is 6 times 7?", "role": "user"} + ], + "llm": { + "id": "msg_bdrk_011mH16vLzW8J9AF3FP5JaCs", + "object": "chat.completion", + "created": 1783556133, + "model": "claude-sonnet-4-5-20250929", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "6 times 7 is 42.", + "reasoning_content": [ + { + "content": "This is a simple multiplication question. 6 times 7 equals 42.", + "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ==" + } + ] + }, + "finish_reason": "stop" + } + ], + "usage": {"completion_tokens": 39, "prompt_tokens": 45, "total_tokens": 84} + } + }, + "final_result": { + "id": "msg_bdrk_011mH16vLzW8J9AF3FP5JaCs", + "object": "chat.completion", + "created": 1783556133, + "model": "claude-sonnet-4-5-20250929", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "6 times 7 is 42.", + "reasoning_content": [ + { + "content": "This is a simple multiplication question. 6 times 7 equals 42.", + "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ==" + } + ] + }, + "finish_reason": "stop" + } + ], + "usage": {"completion_tokens": 39, "prompt_tokens": 45, "total_tokens": 84} + } +} diff --git a/orchestration/src/test/resources/__files/reasoningResponse.json b/orchestration/src/test/resources/__files/reasoningResponse.json new file mode 100644 index 000000000..36563f99f --- /dev/null +++ b/orchestration/src/test/resources/__files/reasoningResponse.json @@ -0,0 +1,65 @@ +{ + "request_id": "47390784-5290-9a01-8054-0aaafc0080f7", + "intermediate_results": { + "templating": [ + { + "content": "Think carefully and explain step by step: Why is the sky blue?", + "role": "user" + } + ], + "llm": { + "id": "msg_bdrk_013jkzoRw22UxBrRiXxXTRGc", + "object": "chat.completion", + "created": 1783555720, + "model": "claude-sonnet-4-5-20250929", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "# Why the Sky is Blue: A Step-by-Step Explanation\n\n## Step 1: Sunlight is Made of Many Colors\n- Sunlight appears white, but it's actually composed of all colors of the visible spectrum (red, orange, yellow, green, blue, indigo, violet)\n- These colors have different wavelengths - red has the longest, violet has the shortest\n\n## Step 2: Sunlight Enters Earth's Atmosphere\n- When sunlight reaches Earth, it must pass through our atmosphere\n- The atmosphere contains tiny molecules of gas (mostly nitrogen and oxygen)\n\n## Step 3: Rayleigh Scattering Occurs\n- As sunlight collides with these tiny atmospheric molecules, it scatters in all directions\n- **Rayleigh scattering** is the key: shorter wavelengths scatter much more than longer wavelengths\n- Blue and violet light (short wavelengths) scatter approximately 10 times more than red light (long wavelength)\n\n## Step 4: Why Blue and Not Violet?\nSince violet scatters even more than blue, you might wonder why the sky isn't violet:\n- Our eyes are less sensitive to violet light\n- The sun emits less violet light than blue\n- Some violet light is absorbed by the upper atmosphere\n\n## Step 5: The Result\n- Blue light bounces around the sky in all directions\n- No matter where you look, scattered blue light reaches your eyes\n- This creates the blue \"dome\" effect we see\n\n**At sunset/sunrise:** The sun's light travels through more atmosphere, scattering away most blue light, leaving the reds and oranges we see.", + "reasoning_content": [ + { + "content": "This is a classic question about atmospheric physics. Let me explain this step by step, carefully and accurately.\n\nThe sky appears blue due to a phenomenon called Rayleigh scattering. Let me break this down:\n\n1. Sunlight composition\n2. Interaction with atmosphere\n3. Rayleigh scattering\n4. Why we see blue specifically\n5. Related phenomena\n\nLet me work through each step carefully.", + "signature": "EtAECnAIDxABGAIqQOKoKaJr9CoLA0RWx4FiAuN/ix+c6XzRsR4j1qqbTCyFvU4vKCMhAa64pWN/KODOISL4ivPR/SbqzMMRjqlf/LQyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgzrJj94teHdUB0YYTQaDPWHrEfYOaLA4uXQmSIwkSENGMxXoKkeQlxVFmoKondwHCWbQpyZwEJ6R6TBVwKSeVHaHO3JinB/JhzNOEWyKo0DhSU159efWblPZapxyvzxlaceJRa2e87KdRTfoUAuxhm4OcHjrzEl5TUPvNHDKwm/PYU53Tu3MEa8ByTn8In5UTRTfam+LP8j/9xOWwGSh6drg2Xnvf5MgxMQx00sOwTNUGUYfTBNuPb6WKRfafp2DhmRYYwnE/PyLZnZT0HQihOVk6xPKY4cKALryz4e3LxcMM2cEDhqisHqbv8FjPNDUU7xw0M1K9s+oig/8iNpHlBuKmvXmJkLVs+To6VZZNs2OG3KpXREZtiS3I9zJFJC07fglAK0cknt21fD5frvDG2C/wXo0GETMrIl7RhhI9bU8HTY14zjQW47FLwFRhMz0Ybk5Cwe4V2jVVbdAha/dwMRKbqccdNIgdbTKgfHPQUsmTjCPLZq8U8HuNSuEJI8njPE9mrZO4ufvgDI4b57ccDwXFHGLHFM4PCyIhQp8kQNhTj20yrU0aq9PEcrRpGx6i2KnWo/67pl/KffNhYOrAoJe7tlrtASCJR99cpOsDzblG9UT+OUwUMbVQgfghgB" + } + ] + }, + "finish_reason": "stop" + } + ], + "usage": { + "completion_tokens": 466, + "prompt_tokens": 50, + "total_tokens": 516 + } + } + }, + "final_result": { + "id": "msg_bdrk_013jkzoRw22UxBrRiXxXTRGc", + "object": "chat.completion", + "created": 1783555720, + "model": "claude-sonnet-4-5-20250929", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "# Why the Sky is Blue: A Step-by-Step Explanation\n\n## Step 1: Sunlight is Made of Many Colors\n- Sunlight appears white, but it's actually composed of all colors of the visible spectrum (red, orange, yellow, green, blue, indigo, violet)\n- These colors have different wavelengths - red has the longest, violet has the shortest\n\n## Step 2: Sunlight Enters Earth's Atmosphere\n- When sunlight reaches Earth, it must pass through our atmosphere\n- The atmosphere contains tiny molecules of gas (mostly nitrogen and oxygen)\n\n## Step 3: Rayleigh Scattering Occurs\n- As sunlight collides with these tiny atmospheric molecules, it scatters in all directions\n- **Rayleigh scattering** is the key: shorter wavelengths scatter much more than longer wavelengths\n- Blue and violet light (short wavelengths) scatter approximately 10 times more than red light (long wavelength)\n\n## Step 4: Why Blue and Not Violet?\nSince violet scatters even more than blue, you might wonder why the sky isn't violet:\n- Our eyes are less sensitive to violet light\n- The sun emits less violet light than blue\n- Some violet light is absorbed by the upper atmosphere\n\n## Step 5: The Result\n- Blue light bounces around the sky in all directions\n- No matter where you look, scattered blue light reaches your eyes\n- This creates the blue \"dome\" effect we see\n\n**At sunset/sunrise:** The sun's light travels through more atmosphere, scattering away most blue light, leaving the reds and oranges we see.", + "reasoning_content": [ + { + "content": "This is a classic question about atmospheric physics. Let me explain this step by step, carefully and accurately.\n\nThe sky appears blue due to a phenomenon called Rayleigh scattering. Let me break this down:\n\n1. Sunlight composition\n2. Interaction with atmosphere\n3. Rayleigh scattering\n4. Why we see blue specifically\n5. Related phenomena\n\nLet me work through each step carefully.", + "signature": "EtAECnAIDxABGAIqQOKoKaJr9CoLA0RWx4FiAuN/ix+c6XzRsR4j1qqbTCyFvU4vKCMhAa64pWN/KODOISL4ivPR/SbqzMMRjqlf/LQyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgzrJj94teHdUB0YYTQaDPWHrEfYOaLA4uXQmSIwkSENGMxXoKkeQlxVFmoKondwHCWbQpyZwEJ6R6TBVwKSeVHaHO3JinB/JhzNOEWyKo0DhSU159efWblPZapxyvzxlaceJRa2e87KdRTfoUAuxhm4OcHjrzEl5TUPvNHDKwm/PYU53Tu3MEa8ByTn8In5UTRTfam+LP8j/9xOWwGSh6drg2Xnvf5MgxMQx00sOwTNUGUYfTBNuPb6WKRfafp2DhmRYYwnE/PyLZnZT0HQihOVk6xPKY4cKALryz4e3LxcMM2cEDhqisHqbv8FjPNDUU7xw0M1K9s+oig/8iNpHlBuKmvXmJkLVs+To6VZZNs2OG3KpXREZtiS3I9zJFJC07fglAK0cknt21fD5frvDG2C/wXo0GETMrIl7RhhI9bU8HTY14zjQW47FLwFRhMz0Ybk5Cwe4V2jVVbdAha/dwMRKbqccdNIgdbTKgfHPQUsmTjCPLZq8U8HuNSuEJI8njPE9mrZO4ufvgDI4b57ccDwXFHGLHFM4PCyIhQp8kQNhTj20yrU0aq9PEcrRpGx6i2KnWo/67pl/KffNhYOrAoJe7tlrtASCJR99cpOsDzblG9UT+OUwUMbVQgfghgB" + } + ] + }, + "finish_reason": "stop" + } + ], + "usage": { + "completion_tokens": 466, + "prompt_tokens": 50, + "total_tokens": 516 + } + } +} diff --git a/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json b/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json new file mode 100644 index 000000000..7d9f0822e --- /dev/null +++ b/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json @@ -0,0 +1,29 @@ +{ + "config": { + "modules": { + "prompt_templating": { + "prompt": { + "template": [ + { + "content": "What is 6 times 7?", + "role": "user" + } + ], + "defaults": {}, + "tools": [] + }, + "model": { + "name": "anthropic--claude-4.5-sonnet", + "version": "latest", + "params": { + "reasoning_effort": "medium" + }, + "timeout": 600, + "max_retries": 2 + } + } + } + }, + "placeholder_values": {}, + "messages_history": [] +} diff --git a/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json b/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json new file mode 100644 index 000000000..4d6da5fd8 --- /dev/null +++ b/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json @@ -0,0 +1,38 @@ +{ + "config": { + "modules": { + "prompt_templating": { + "prompt": { + "template": [ + { + "content": "Are you sure?", + "role": "user" + } + ] + }, + "model": { + "name": "anthropic--claude-4.5-sonnet", + "params": { + "reasoning_effort": "medium" + } + } + } + } + }, + "messages_history": [ + { + "content": "What is 6 times 7?", + "role": "user" + }, + { + "role": "assistant", + "content": "6 times 7 is 42.", + "reasoning_content": [ + { + "content": "This is a simple multiplication question. 6 times 7 equals 42.", + "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ==" + } + ] + } + ] +} diff --git a/orchestration/src/test/resources/reasoningHistoryResponse.json b/orchestration/src/test/resources/reasoningHistoryResponse.json new file mode 100644 index 000000000..ef8eddec4 --- /dev/null +++ b/orchestration/src/test/resources/reasoningHistoryResponse.json @@ -0,0 +1,19 @@ +{ + "intermediate_results": { + "templating": [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "hello", + "reasoning_content": [{"content": "past-thought", "signature": "past-sig"}] + } + ] + }, + "final_result": { + "choices": [ + { + "message": {"role": "assistant", "content": "final answer"} + } + ] + } +} diff --git a/orchestration/src/test/resources/reasoningRequest.json b/orchestration/src/test/resources/reasoningRequest.json new file mode 100644 index 000000000..399f20838 --- /dev/null +++ b/orchestration/src/test/resources/reasoningRequest.json @@ -0,0 +1,29 @@ +{ + "config": { + "modules": { + "prompt_templating": { + "prompt": { + "template": [ + { + "content": "Think carefully and explain step by step: Why is the sky blue?", + "role": "user" + } + ], + "defaults": {}, + "tools": [] + }, + "model": { + "name": "anthropic--claude-4.5-sonnet", + "version": "latest", + "params": { + "reasoning_effort": "medium" + }, + "timeout": 600, + "max_retries": 2 + } + } + } + }, + "placeholder_values": {}, + "messages_history": [] +} diff --git a/orchestration/src/test/resources/streamReasoning.txt b/orchestration/src/test/resources/streamReasoning.txt new file mode 100644 index 000000000..cb9c4a108 --- /dev/null +++ b/orchestration/src/test/resources/streamReasoning.txt @@ -0,0 +1,6 @@ +data: {"request_id": "req", "intermediate_results": {"templating": [{"role": "user", "content": "hi"}]}, "final_result": {"id": "", "object": "", "created": 0, "model": "", "choices": [{"index": 0, "delta": {"role": "", "content": ""}, "finish_reason": ""}]}} +data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "Let me ", "signature": ""}]}, "finish_reason": ""}]}} +data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "think.", "signature": ""}]}, "finish_reason": ""}]}} +data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "", "signature": "sig-final"}]}, "finish_reason": ""}]}} +data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "42"}, "finish_reason": "stop"}]}} +data: [DONE] diff --git a/orchestration/src/test/resources/streamReasoningRequest.json b/orchestration/src/test/resources/streamReasoningRequest.json new file mode 100644 index 000000000..a84115526 --- /dev/null +++ b/orchestration/src/test/resources/streamReasoningRequest.json @@ -0,0 +1,30 @@ +{ + "config": { + "modules": { + "prompt_templating": { + "prompt": { + "template": [ + { + "content": "Think carefully and explain step by step: Why is the sky blue?", + "role": "user" + } + ], + "defaults": {}, + "tools": [] + }, + "model": { + "name": "anthropic--claude-4.5-sonnet", + "version": "latest", + "params": { + "reasoning_effort": "medium" + }, + "timeout": 600, + "max_retries": 2 + } + } + }, + "stream": {"enabled": true, "chunk_size": 100} + }, + "placeholder_values": {}, + "messages_history": [] +} diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java index bff2c8e1d..441294929 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java @@ -79,6 +79,46 @@ ResponseEntity streamChatCompletion() { return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(emitter); } + @GetMapping("/reasoning") + Object reasoning( + @RequestParam(value = "topic", defaultValue = "Why is the sky blue?") final String topic) { + return service.reasoning(topic); + } + + @GetMapping("/multiTurnReasoning") + Object multiTurnReasoning( + @RequestParam(value = "first", defaultValue = "What is 6 times 7?") final String first, + @RequestParam(value = "followUp", defaultValue = "Are you sure?") final String followUp) { + return service.multiTurnReasoning(first, followUp); + } + + @GetMapping("/streamReasoning") + ResponseEntity streamReasoning( + @RequestParam(value = "topic", defaultValue = "Why is the sky blue?") final String topic) { + final var emitter = new ResponseBodyEmitter(); + final Runnable consumeStream = + () -> { + try { + final var accumulated = + service.streamReasoning( + topic, + answer -> send(emitter, "[answer] " + answer), + reasoning -> send(emitter, "[reasoning] " + reasoning)); + // Emit the fully accumulated reasoning text at the end of the stream. + for (int i = 0; i < accumulated.size(); i++) { + send(emitter, "[accumulated block " + i + "] " + accumulated.get(i)); + } + } finally { + emitter.complete(); + } + }; + + ThreadContextExecutors.getExecutor().execute(consumeStream); + + // TEXT_EVENT_STREAM allows the browser to display the content as it is streamed + return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(emitter); + } + @GetMapping("/template") Object template(@Nullable @RequestParam(value = "format", required = false) final String format) { final var response = service.template("German"); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java index f653681cc..38769a7fd 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java @@ -1,5 +1,6 @@ package com.sap.ai.sdk.app.services; +import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.CLAUDE_4_5_SONNET; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GEMINI_2_5_FLASH; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_41_NANO; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_5_MINI; @@ -27,6 +28,8 @@ import com.sap.ai.sdk.orchestration.OrchestrationEmbeddingResponse; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; +import com.sap.ai.sdk.orchestration.ReasoningAccumulator; +import com.sap.ai.sdk.orchestration.ReasoningEffort; import com.sap.ai.sdk.orchestration.ResponseJsonSchema; import com.sap.ai.sdk.orchestration.SystemMessage; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -57,6 +60,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -179,6 +183,95 @@ public Stream streamChatCompletion(@Nonnull final String topic) { return client.streamChatCompletion(prompt, config); } + /** + * Chat request to a reasoning-capable model through the Orchestration service, returning both the + * final answer and the reasoning (thinking) trace. + * + * @param topic the topic to ask about. + * @return a {@link ReasoningResult} with the answer and the reasoning blocks. + */ + @Nonnull + public ReasoningResult reasoning(@Nonnull final String topic) { + val reasoningConfig = + config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + val prompt = new OrchestrationPrompt("Think carefully and explain step by step: " + topic); + val response = client.chatCompletion(prompt, reasoningConfig); + return new ReasoningResult(response.getContent(), response.getReasoningContent()); + } + + /** + * The result of a reasoning-model chat request: the final answer plus the model's reasoning + * (thinking) text. + * + * @param answer the final assistant reply. + * @param reasoning the reasoning text produced by the model (may be empty). + */ + public record ReasoningResult(@Nonnull String answer, @Nonnull List reasoning) {} + + /** + * Streaming chat request to a reasoning-capable model. Answer and reasoning chunks are pushed + * live to the given consumers; the accumulated reasoning text is returned. + * + * @param topic the topic to ask about. + * @param onAnswerChunk consumer invoked with each answer text chunk. + * @param onReasoningChunk consumer invoked with each reasoning text chunk. + * @return the assembled reasoning text as returned by the API. + */ + @Nonnull + public List streamReasoning( + @Nonnull final String topic, + @Nonnull final Consumer onAnswerChunk, + @Nonnull final Consumer onReasoningChunk) { + val reasoningConfig = + config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + val prompt = new OrchestrationPrompt("Think carefully and explain step by step: " + topic); + val request = OrchestrationClient.toCompletionPostRequest(prompt, reasoningConfig); + val accumulator = new ReasoningAccumulator(); + try (val stream = client.streamChatCompletionDeltas(request)) { + stream.forEach( + delta -> { + val content = delta.getDeltaContent(); + if (!content.isEmpty()) { + onAnswerChunk.accept(content); + } + for (val reasoningText : delta.getDeltaReasoningContent()) { + if (!reasoningText.isEmpty()) { + onReasoningChunk.accept(reasoningText); + } + } + accumulator.accept(delta); + }); + } + return accumulator.assemble(); + } + + /** + * Two-turn conversation against a reasoning-capable model. + * + * @param firstQuestion the initial question. + * @param followUp the follow-up question. + * @return the second turn's assistant reply. + */ + @Nonnull + public ReasoningResult multiTurnReasoning( + @Nonnull final String firstQuestion, @Nonnull final String followUp) { + val reasoningConfig = + config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM)); + + // Turn 1 + val firstPrompt = new OrchestrationPrompt(firstQuestion); + val firstResponse = client.chatCompletion(firstPrompt, reasoningConfig); + + // Turn 2 feeds the whole history back. getAllMessages() already carries the reasoning content + // on the final assistant message. + val followUpPrompt = + new OrchestrationPrompt(followUp).messageHistory(firstResponse.getAllMessages()); + val followUpResponse = client.chatCompletion(followUpPrompt, reasoningConfig); + + return new ReasoningResult( + followUpResponse.getContent(), followUpResponse.getReasoningContent()); + } + /** * Chat request to OpenAI through the Orchestration service with a template. * diff --git a/sample-code/spring-app/src/main/resources/static/index.html b/sample-code/spring-app/src/main/resources/static/index.html index be8accffe..33073f349 100644 --- a/sample-code/spring-app/src/main/resources/static/index.html +++ b/sample-code/spring-app/src/main/resources/static/index.html @@ -266,6 +266,42 @@

Orchestration

+
  • +
    + +
    + Chat request against a reasoning-capable model. Returns both the final answer and the reasoning blocks. +
    +
    +
  • +
  • +
    + +
    + Streaming variant that emits answer and reasoning chunks live, followed by the accumulated reasoning blocks. +
    +
    +
  • +
  • +
    + +
    + Two-turn conversation demonstrating re-submission of reasoning_content in messages_history. +
    +
    +