From c26d5f624c4cf1fa34b778b7ca0c1e72b634a04c Mon Sep 17 00:00:00 2001
From: Alexander Hewer <10650633+ahewer@users.noreply.github.com>
Date: Fri, 24 May 2024 10:30:27 +0200
Subject: [PATCH] Added support for response format in chat options for Azure
OpenAI
- Added new enum class AzureOpenAiResponseFormat
- Modified merge methods in AzureOpenAiChatModel to use new parameter
- Modfied AzureOpenAiChatOptions to include the responseFormat parameter
- Modified Azure ChatCompletionsOptionsTests to verify that new parameter is working as expected
- Added docs
---
.../ai/azure/openai/AzureOpenAiChatModel.java | 30 ++++++++++++++
.../azure/openai/AzureOpenAiChatOptions.java | 22 ++++++++++
.../openai/AzureOpenAiResponseFormat.java | 41 +++++++++++++++++++
.../AzureChatCompletionsOptionsTests.java | 6 +++
.../pages/api/chat/azure-openai-chat.adoc | 1 +
.../api/structured-output-converter.adoc | 1 +
6 files changed, 101 insertions(+)
create mode 100644 models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiResponseFormat.java
diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatModel.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatModel.java
index 6f8f9c99f..dd53e9503 100644
--- a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatModel.java
+++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatModel.java
@@ -32,6 +32,9 @@ import com.azure.ai.openai.models.CompletionsFinishReason;
import com.azure.ai.openai.models.ContentFilterResultsForPrompt;
import com.azure.ai.openai.models.FunctionCall;
import com.azure.ai.openai.models.FunctionDefinition;
+import com.azure.ai.openai.models.ChatCompletionsJsonResponseFormat;
+import com.azure.ai.openai.models.ChatCompletionsTextResponseFormat;
+import com.azure.ai.openai.models.ChatCompletionsResponseFormat;
import com.azure.core.util.BinaryData;
import com.azure.core.util.IterableStream;
import org.slf4j.Logger;
@@ -349,6 +352,11 @@ public class AzureOpenAiChatModel
mergedAzureOptions.setPresencePenalty(toSpringAiOptions.getPresencePenalty().doubleValue());
}
+ mergedAzureOptions.setResponseFormat(fromAzureOptions.getResponseFormat());
+ if (mergedAzureOptions.getResponseFormat() == null && toSpringAiOptions.getResponseFormat() != null) {
+ mergedAzureOptions.setResponseFormat(toAzureResponseFormat(toSpringAiOptions.getResponseFormat()));
+ }
+
mergedAzureOptions.setN(fromAzureOptions.getN() != null ? fromAzureOptions.getN() : toSpringAiOptions.getN());
mergedAzureOptions
@@ -417,6 +425,10 @@ public class AzureOpenAiChatModel
mergedAzureOptions.setModel(fromSpringAiOptions.getDeploymentName());
}
+ if (fromSpringAiOptions.getResponseFormat() != null) {
+ mergedAzureOptions.setResponseFormat(toAzureResponseFormat(fromSpringAiOptions.getResponseFormat()));
+ }
+
return mergedAzureOptions;
}
@@ -465,6 +477,9 @@ public class AzureOpenAiChatModel
if (fromOptions.getModel() != null) {
mergedOptions.setModel(fromOptions.getModel());
}
+ if (fromOptions.getResponseFormat() != null) {
+ mergedOptions.setResponseFormat(fromOptions.getResponseFormat());
+ }
return mergedOptions;
}
@@ -509,6 +524,9 @@ public class AzureOpenAiChatModel
if (fromOptions.getModel() != null) {
copyOptions.setModel(fromOptions.getModel());
}
+ if (fromOptions.getResponseFormat() != null) {
+ copyOptions.setResponseFormat(fromOptions.getResponseFormat());
+ }
return copyOptions;
}
@@ -590,4 +608,16 @@ public class AzureOpenAiChatModel
return choice.getFinishReason() == CompletionsFinishReason.TOOL_CALLS;
}
+ /**
+ * Maps the SpringAI response format to the Azure response format
+ * @param responseFormat SpringAI response format
+ * @return Azure response format
+ */
+ private ChatCompletionsResponseFormat toAzureResponseFormat(AzureOpenAiResponseFormat responseFormat) {
+ if (responseFormat == AzureOpenAiResponseFormat.JSON) {
+ return new ChatCompletionsJsonResponseFormat();
+ }
+ return new ChatCompletionsTextResponseFormat();
+ }
+
}
diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java
index 6e2d4f5eb..d701a4638 100644
--- a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java
+++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiChatOptions.java
@@ -126,6 +126,14 @@ public class AzureOpenAiChatOptions implements FunctionCallingOptions, ChatOptio
@JsonProperty(value = "deployment_name")
private String deploymentName;
+ /**
+ * The response format expected from the Azure OpenAI model
+ * @see org.springframework.ai.azure.openai.AzureOpenAiResponseFormat for supported
+ * formats
+ */
+ @JsonProperty("response_format")
+ private AzureOpenAiResponseFormat responseFormat;
+
/**
* OpenAI Tool Function Callbacks to register with the ChatModel. For Prompt Options
* the functionCallbacks are automatically enabled for the duration of the prompt
@@ -239,6 +247,12 @@ public class AzureOpenAiChatOptions implements FunctionCallingOptions, ChatOptio
return this;
}
+ public Builder withResponseFormat(AzureOpenAiResponseFormat responseFormat) {
+ Assert.notNull(responseFormat, "responseFormat must not be null");
+ this.options.responseFormat = responseFormat;
+ return this;
+ }
+
public AzureOpenAiChatOptions build() {
return this.options;
}
@@ -356,6 +370,14 @@ public class AzureOpenAiChatOptions implements FunctionCallingOptions, ChatOptio
this.functions = functions;
}
+ public AzureOpenAiResponseFormat getResponseFormat() {
+ return this.responseFormat;
+ }
+
+ public void setResponseFormat(AzureOpenAiResponseFormat responseFormat) {
+ this.responseFormat = responseFormat;
+ }
+
public static AzureOpenAiChatOptions fromOptions(AzureOpenAiChatOptions fromOptions) {
return builder().withDeploymentName(fromOptions.getDeploymentName())
.withFrequencyPenalty(
diff --git a/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiResponseFormat.java b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiResponseFormat.java
new file mode 100644
index 000000000..31bcb7458
--- /dev/null
+++ b/models/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/AzureOpenAiResponseFormat.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 - 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.azure.openai;
+
+/**
+ * Utility enumeration for representing the response format that may be requested from the
+ * Azure OpenAI model. Please check OpenAI
+ * API documentation for more details.
+ */
+public enum AzureOpenAiResponseFormat {
+
+ // default value used by OpenAI
+ TEXT,
+ /*
+ * From the OpenAI API documentation: Compatability: Compatible with GPT-4 Turbo and
+ * all GPT-3.5 Turbo models newer than gpt-3.5-turbo-1106. Caveats: This enables JSON
+ * mode, which guarantees the message the model generates is valid JSON. Important:
+ * when using JSON mode, you must also instruct the model to produce JSON yourself via
+ * a system or user message. Without this, the model may generate an unending stream
+ * of whitespace until the generation reaches the token limit, resulting in a
+ * long-running and seemingly "stuck" request. Also note that the message content may
+ * be partially cut off if finish_reason="length", which indicates the generation
+ * exceeded max_tokens or the conversation exceeded the max context length.
+ */
+ JSON
+
+}
diff --git a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java
index 1bbcc5e8f..e9c8196ff 100644
--- a/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java
+++ b/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureChatCompletionsOptionsTests.java
@@ -16,6 +16,8 @@
package org.springframework.ai.azure.openai;
import com.azure.ai.openai.OpenAIClient;
+import com.azure.ai.openai.models.ChatCompletionsJsonResponseFormat;
+import com.azure.ai.openai.models.ChatCompletionsTextResponseFormat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -51,6 +53,7 @@ public class AzureChatCompletionsOptionsTests {
.withStop(List.of("foo", "bar"))
.withTopP(0.69f)
.withUser("user")
+ .withResponseFormat(AzureOpenAiResponseFormat.TEXT)
.build();
var client = new AzureOpenAiChatModel(mockClient, defaultOptions);
@@ -69,6 +72,7 @@ public class AzureChatCompletionsOptionsTests {
assertThat(requestOptions.getStop()).isEqualTo(List.of("foo", "bar"));
assertThat(requestOptions.getTopP()).isEqualTo(0.69f);
assertThat(requestOptions.getUser()).isEqualTo("user");
+ assertThat(requestOptions.getResponseFormat()).isInstanceOf(ChatCompletionsTextResponseFormat.class);
var runtimeOptions = AzureOpenAiChatOptions.builder()
.withDeploymentName("PROMPT_MODEL")
@@ -81,6 +85,7 @@ public class AzureChatCompletionsOptionsTests {
.withStop(List.of("foo", "bar"))
.withTopP(0.111f)
.withUser("user2")
+ .withResponseFormat(AzureOpenAiResponseFormat.JSON)
.build();
requestOptions = client.toAzureChatCompletionsOptions(new Prompt("Test message content", runtimeOptions));
@@ -97,6 +102,7 @@ public class AzureChatCompletionsOptionsTests {
assertThat(requestOptions.getStop()).isEqualTo(List.of("foo", "bar"));
assertThat(requestOptions.getTopP()).isEqualTo(0.111f);
assertThat(requestOptions.getUser()).isEqualTo("user2");
+ assertThat(requestOptions.getResponseFormat()).isInstanceOf(ChatCompletionsJsonResponseFormat.class);
}
private static Stream providePresencePenaltyAndFrequencyPenaltyTest() {
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc
index 88a7519ed..0aef13095 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/azure-openai-chat.adoc
@@ -106,6 +106,7 @@ Deployments model name to provide as part of this completions request.
| spring.ai.azure.openai.chat.options.n | The number of chat completions choices that should be generated for a chat completions response. | -
| spring.ai.azure.openai.chat.options.stop | A collection of textual sequences that will end completions generation. | -
| spring.ai.azure.openai.chat.options.presencePenalty | A value that influences the probability of generated tokens appearing based on their existing presence in generated text. Positive values will make tokens less likely to appear when they already exist and increase the model's likelihood to output new topics. | -
+| spring.ai.azure.openai.chat.options.responseFormat | An object specifying the format that the model must output. Using `AzureOpenAiResponseFormat.JSON` enables JSON mode, which guarantees the message the model generates is valid JSON. Using AzureOpenAiResponseFormat.TEXT enables TEXT mode.| -
| spring.ai.azure.openai.chat.options.frequencyPenalty | A value that influences the probability of generated tokens appearing based on their cumulative frequency in generated text. Positive values will make tokens less likely to appear as their frequency increases and decrease the likelihood of the model repeating the same statements verbatim. | -
|====
diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc
index 47b63233e..6346d4e5c 100644
--- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc
+++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc
@@ -218,6 +218,7 @@ The following AI Models have been tested to support List, Map and Bean structure
Some AI Models provide dedicated configuration options to generate structured (usually JSON) output.
* xref:api/chat/openai-chat.adoc[OpenAI] - provides a `spring.ai.openai.chat.options.responseFormat` options specifying the format that the model must output. Setting to `{ "type": "json_object" }` enables JSON mode, which guarantees the message the model generates is valid JSON.
+* xref:api/chat/azure-openai-chat.adoc[Azure OpenAI] - provides a `spring.ai.azure.openai.chat.options.responseFormat` options specifying the format that the model must output. Setting to `{ "type": "json_object" }` enables JSON mode, which guarantees the message the model generates is valid JSON.
* xref:api/chat/ollama-chat.adoc[Ollama] - provides a `spring.ai.ollama.chat.options.format` option to specify the format to return a response in. Currently the only accepted value is `json`.
* xref:api/chat/mistralai-chat.adoc[Mistral AI] - provides a `spring.ai.mistralai.chat.options.responseFormat` option to specify the format to return a response in. Setting to `{ "type": "json_object" }` enables JSON mode, which guarantees the message the model generates is valid JSON.