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..29e83c5bb 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,39 @@ 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. */ + @Getter(AccessLevel.NONE) + @Nullable + List reasoningContent; + /** * Creates a new assistant message with the given single message. * @@ -39,6 +47,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 +58,7 @@ public AssistantMessage(@Nonnull final String singleMessage) { AssistantMessage(@Nonnull final MessageContent content) { this.content = content; toolCalls = null; + reasoningContent = null; } /** @@ -61,12 +71,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 +86,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 +109,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 41064c274..d4bd71c5d 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 @@ -514,6 +514,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. * @@ -539,6 +552,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..373e4c9fe 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,44 @@ 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. The + * reasoning blocks are concatenated with no separator between them. + * + * @return the concatenated reasoning text; empty if there is no reasoning. + * @see #getReasoningText(String) + */ + @Nonnull + public String getReasoningText() { + return getReasoningText(""); + } + + /** + * Get the reasoning (thinking) content as a single joined string, for display purposes, with the + * reasoning blocks separated by the given delimiter. + * + * @param delimiter the delimiter to place between reasoning blocks. + * @return the joined reasoning text; empty if there is no reasoning. + */ + @Nonnull + public String getReasoningText(@Nonnull final String delimiter) { + return String.join(delimiter, getReasoningContent()); + } + /** * Get all messages. This can be used for subsequent prompts as a message history. * @@ -80,13 +119,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 +151,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/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..bcbb49c6a --- /dev/null +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java @@ -0,0 +1,177 @@ +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 getReasoningTextJoinsBlocksWithDelimiter() throws Exception { + val response = + parseSyncResponse( + ", \"reasoning_content\": [" + + "{\"content\": \"first\", \"signature\": \"sig-a\"}," + + "{\"content\": \"second\", \"signature\": \"sig-b\"}]"); + + // No-arg overload defaults to an empty delimiter. + assertThat(response.getReasoningText()).isEqualTo("firstsecond"); + // Explicit delimiter is placed between blocks. + assertThat(response.getReasoningText("\n\n")).isEqualTo("first\n\nsecond"); + } + + @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 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(); + } + + 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..7c5510c0a 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,123 @@ 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 reasoningPrompt = + new OrchestrationPrompt("Think carefully and explain step by step: Why is the sky blue?"); + final var request = + OrchestrationClient.toCompletionPostRequest(reasoningPrompt, reasoningConfig); + + final var answerChunks = new ArrayList(); + final var reasoningChunks = new ArrayList(); + try (var stream = client.streamChatCompletionDeltas(request)) { + stream.forEach( + delta -> { + if (!delta.getDeltaContent().isEmpty()) { + answerChunks.add(delta.getDeltaContent()); + } + reasoningChunks.addAll(delta.getDeltaReasoningContent()); + }); + } + + assertThat(answerChunks).containsExactly("42"); + // Reasoning arrives split across chunks ("Let me " + "think."); joining reconstructs the text. + assertThat(String.join("", reasoningChunks)).isEqualTo("Let me think."); + + final String expectedRequest = fileLoaderStr.apply("streamReasoningRequest.json"); + verify( + postRequestedFor(urlPathEqualTo("/v2/completion")) + .withRequestBody(equalToJson(expectedRequest, true, true))); + } + + @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"); + + final String expectedRequest = fileLoaderStr.apply("reasoningRequest.json"); + verify( + postRequestedFor(urlPathEqualTo("/v2/completion")) + .withRequestBody(equalToJson(expectedRequest, true, true))); + } + + @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 expectedTurn1Request = fileLoaderStr.apply("multiTurnReasoningTurn1Request.json"); + verify( + postRequestedFor(urlPathEqualTo("/v2/completion")) + .withRequestBody(equalToJson(expectedTurn1Request, true, true))); + 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..921169e02 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,54 @@ 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 = + () -> { + final var lastLabel = new String[] {""}; + + try (var stream = service.streamReasoning(topic)) { + stream.forEach( + chunk -> { + if (!chunk.answer().isEmpty()) { + if (!"answer".equals(lastLabel[0])) { + send(emitter, "\n-----ANSWER-----\n"); + lastLabel[0] = "answer"; + } + send(emitter, chunk.answer()); + } + for (final var reasoning : chunk.reasoning()) { + if (!reasoning.isEmpty()) { + if (!"reasoning".equals(lastLabel[0])) { + send(emitter, "\n----REASONING----\n"); + lastLabel[0] = "reasoning"; + } + send(emitter, reasoning); + } + } + }); + + 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..bb31a0f84 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,7 @@ 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.ReasoningEffort; import com.sap.ai.sdk.orchestration.ResponseJsonSchema; import com.sap.ai.sdk.orchestration.SystemMessage; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -179,6 +181,78 @@ 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 ReasoningOutput} with the answer and the reasoning blocks. + */ + @Nonnull + public ReasoningOutput 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 ReasoningOutput(response.getContent(), response.getReasoningContent()); + } + + /** + * Answer and reasoning (thinking) text from a reasoning-capable model. + * + * @param answer the answer text. + * @param reasoning the reasoning text blocks (may be empty). + */ + public record ReasoningOutput(@Nonnull String answer, @Nonnull List reasoning) {} + + /** + * Streaming chat request to a reasoning-capable model. Returns a stream of {@link + * ReasoningOutput}, each carrying the answer and reasoning text of one delta, in the order the + * API produced them. + * + * @param topic the topic to ask about. + * @return a stream of reasoning outputs, one per delta. + */ + @Nonnull + public Stream streamReasoning(@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 request = OrchestrationClient.toCompletionPostRequest(prompt, reasoningConfig); + return client + .streamChatCompletionDeltas(request) + .map( + delta -> + new ReasoningOutput(delta.getDeltaContent(), delta.getDeltaReasoningContent())); + } + + /** + * 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 ReasoningOutput 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 ReasoningOutput( + 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. +
    +
    +