OpenAi: Add support for structured outputs and JSON schema
- Added support for OpenAI's structured outputs feature, which allows specifying a JSON schema for the model to match - Introduced new record to configure the desired response format - Added support for configuring the response format via application properties or the chat options builder - Extend teh BeanOutputConverter to help generate JSON schema from a target domain object and convert the response. - Added comprehensive tests to cover the new response format functionality Resolves #1196
This commit is contained in:
committed by
Mark Pollack
parent
866b262cdd
commit
91afed5ae5
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.ResponseErrorHandler;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
@@ -521,7 +522,53 @@ public class OpenAiApi {
|
||||
*/
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public record ResponseFormat(
|
||||
@JsonProperty("type") String type) {
|
||||
@JsonProperty("type") Type type,
|
||||
@JsonProperty("json_schema") JsonSchema jsonSchema ) {
|
||||
|
||||
public enum Type {
|
||||
/**
|
||||
* Enables JSON mode, which guarantees the message
|
||||
* the model generates is valid JSON.
|
||||
*/
|
||||
@JsonProperty("json_object")
|
||||
JSON_OBJECT,
|
||||
|
||||
/**
|
||||
* Enables Structured Outputs which guarantees the model
|
||||
* will match your supplied JSON schema.
|
||||
*/
|
||||
@JsonProperty("json_schema")
|
||||
JSON_SCHEMA
|
||||
}
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public record JsonSchema(
|
||||
@JsonProperty("name") String name,
|
||||
@JsonProperty("schema") Map<String, Object> schema,
|
||||
@JsonProperty("strict") Boolean strict) {
|
||||
|
||||
public JsonSchema(String name, String schema) {
|
||||
this(name, ModelOptionsUtils.jsonToMap(schema), true);
|
||||
}
|
||||
|
||||
public JsonSchema(String name, String schema, Boolean strict) {
|
||||
this(StringUtils.hasText(name)? name : "custom_response_format_schema", ModelOptionsUtils.jsonToMap(schema), strict);
|
||||
}
|
||||
}
|
||||
|
||||
public ResponseFormat(Type type) {
|
||||
this(type, (JsonSchema) null);
|
||||
}
|
||||
|
||||
public ResponseFormat(Type type, String jsonSchena) {
|
||||
this(type, "custom_response_format_schema", jsonSchena, true);
|
||||
}
|
||||
|
||||
@ConstructorBinding
|
||||
public ResponseFormat(Type type, String name, String schema, Boolean strict) {
|
||||
this(type, StringUtils.hasText(schema)? new JsonSchema(name, schema, strict): null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,35 +15,38 @@
|
||||
*/
|
||||
package org.springframework.ai.openai.chat;
|
||||
|
||||
import com.fasterxml.jackson.core.JacksonException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatModel;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JacksonException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@SpringBootTest(classes = OpenAiChatModel2IT.Config.class)
|
||||
@SpringBootTest(classes = OpenAiChatModelResponseFormatIT.Config.class)
|
||||
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
|
||||
public class OpenAiChatModel2IT {
|
||||
public class OpenAiChatModelResponseFormatIT {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||
|
||||
@@ -51,7 +54,7 @@ public class OpenAiChatModel2IT {
|
||||
private OpenAiChatModel openAiChatModel;
|
||||
|
||||
@Test
|
||||
void responseFormatTest() throws JsonMappingException, JsonProcessingException {
|
||||
void jsonObject() throws JsonMappingException, JsonProcessingException {
|
||||
|
||||
// 400 - ResponseError[error=Error[message='json' is not one of ['json_object',
|
||||
// 'text'] -
|
||||
@@ -64,7 +67,7 @@ public class OpenAiChatModel2IT {
|
||||
|
||||
Prompt prompt = new Prompt("List 8 planets. Use JSON response",
|
||||
OpenAiChatOptions.builder()
|
||||
.withResponseFormat(new ChatCompletionRequest.ResponseFormat("json_object"))
|
||||
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
|
||||
.build());
|
||||
|
||||
ChatResponse response = this.openAiChatModel.call(prompt);
|
||||
@@ -78,6 +81,90 @@ public class OpenAiChatModel2IT {
|
||||
assertThat(isValidJson(content)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonSchema() throws JsonMappingException, JsonProcessingException {
|
||||
|
||||
var jsonSchema = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": { "type": "string" },
|
||||
"output": { "type": "string" }
|
||||
},
|
||||
"required": ["explanation", "output"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"final_answer": { "type": "string" }
|
||||
},
|
||||
"required": ["steps", "final_answer"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
""";
|
||||
|
||||
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
|
||||
OpenAiChatOptions.builder()
|
||||
.withModel(ChatModel.GPT_4_O_MINI)
|
||||
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, jsonSchema))
|
||||
.build());
|
||||
|
||||
ChatResponse response = this.openAiChatModel.call(prompt);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
|
||||
String content = response.getResult().getOutput().getContent();
|
||||
|
||||
logger.info("Response content: {}", content);
|
||||
|
||||
assertThat(isValidJson(content)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonSchemaBeanConverter() throws JsonMappingException, JsonProcessingException {
|
||||
|
||||
record MathReasoning(@JsonProperty(required = true, value = "steps") Steps steps,
|
||||
@JsonProperty(required = true, value = "final_answer") String finalAnswer) {
|
||||
|
||||
record Steps(@JsonProperty(required = true, value = "items") Items[] items) {
|
||||
|
||||
record Items(@JsonProperty(required = true, value = "explanation") String explanation,
|
||||
@JsonProperty(required = true, value = "output") String output) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var outputConverter = new BeanOutputConverter<>(MathReasoning.class);
|
||||
|
||||
var jsonSchema1 = outputConverter.getJsonSchema();
|
||||
|
||||
System.out.println(jsonSchema1);
|
||||
|
||||
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
|
||||
OpenAiChatOptions.builder()
|
||||
.withModel(ChatModel.GPT_4_O_MINI)
|
||||
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, jsonSchema1))
|
||||
.build());
|
||||
|
||||
ChatResponse response = this.openAiChatModel.call(prompt);
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
|
||||
String content = response.getResult().getOutput().getContent();
|
||||
|
||||
logger.info("Response content: {}", content);
|
||||
|
||||
MathReasoning mathReasoning = outputConverter.convert(content);
|
||||
|
||||
System.out.println(mathReasoning);
|
||||
|
||||
assertThat(isValidJson(content)).isTrue();
|
||||
}
|
||||
|
||||
private static ObjectMapper MAPPER = new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||
|
||||
public static boolean isValidJson(String json) {
|
||||
@@ -35,10 +35,12 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectWriter;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.github.victools.jsonschema.generator.Option;
|
||||
import com.github.victools.jsonschema.generator.SchemaGenerator;
|
||||
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
|
||||
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
|
||||
import com.github.victools.jsonschema.module.jackson.JacksonModule;
|
||||
import com.github.victools.jsonschema.module.jackson.JacksonOption;
|
||||
|
||||
/**
|
||||
* An implementation of {@link StructuredOutputConverter} that transforms the LLM output
|
||||
@@ -140,9 +142,10 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
|
||||
* Generates the JSON schema for the target type.
|
||||
*/
|
||||
private void generateSchema() {
|
||||
JacksonModule jacksonModule = new JacksonModule();
|
||||
JacksonModule jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
|
||||
SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(DRAFT_2020_12, PLAIN_JSON)
|
||||
.with(jacksonModule);
|
||||
.with(jacksonModule)
|
||||
.with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);
|
||||
SchemaGeneratorConfig config = configBuilder.build();
|
||||
SchemaGenerator generator = new SchemaGenerator(config);
|
||||
JsonNode jsonNode = generator.generateSchema(this.typeRef.getType());
|
||||
@@ -205,4 +208,12 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
|
||||
return String.format(template, this.jsonSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the generated JSON schema for the target type.
|
||||
* @return The generated JSON schema.
|
||||
*/
|
||||
public String getJsonSchema() {
|
||||
return this.jsonSchema;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ class BeanOutputConverterTest {
|
||||
"someString" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
""");
|
||||
}
|
||||
@@ -156,7 +157,8 @@ class BeanOutputConverterTest {
|
||||
"someString" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
""");
|
||||
}
|
||||
@@ -181,7 +183,8 @@ class BeanOutputConverterTest {
|
||||
"someString" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}
|
||||
}```
|
||||
""");
|
||||
@@ -199,7 +202,8 @@ class BeanOutputConverterTest {
|
||||
"type" : "string",
|
||||
"description" : "string_property_description"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
""");
|
||||
}
|
||||
@@ -217,7 +221,8 @@ class BeanOutputConverterTest {
|
||||
"type" : "string",
|
||||
"description" : "string_property_description"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}```
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -97,14 +97,18 @@ The prefix `spring.ai.openai.chat` is the property prefix that lets you configur
|
||||
| spring.ai.openai.chat.api-key | Optional overrides the spring.ai.openai.api-key to provide chat specific api-key | -
|
||||
| spring.ai.openai.chat.organization-id | Optionally you can specify which organization used for an API request. | -
|
||||
| spring.ai.openai.chat.project-id | Optionally, you can specify which project is used for an API request. | -
|
||||
| spring.ai.openai.chat.options.model | This is the OpenAI Chat model to use. `gpt-4o`, `gpt-4-turbo`, `gpt-4-turbo-2024-04-09`, `gpt-4-0125-preview`, `gpt-4-turbo-preview`, `gpt-3.5-turbo`, `gpt-3.5-turbo-0125`, `gpt-3.5-turbo-1106`. See the https://platform.openai.com/docs/models[models] page for more information. | `gpt-3.5-turbo`
|
||||
| spring.ai.openai.chat.options.model | Name of the the OpenAI Chat model to use. You can select between models such as: `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `gpt-3.5-turbo` ... See the https://platform.openai.com/docs/models[models] page for more information. | `gpt-4o`
|
||||
| spring.ai.openai.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify temperature and top_p for the same completions request as the interaction of these two settings is difficult to predict. | 0.8
|
||||
| spring.ai.openai.chat.options.frequencyPenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. | 0.0f
|
||||
| spring.ai.openai.chat.options.logitBias | Modify the likelihood of specified tokens appearing in the completion. | -
|
||||
| spring.ai.openai.chat.options.maxTokens | The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length. | -
|
||||
| spring.ai.openai.chat.options.n | How many chat completion choices to generate for each input message. Note that you will be charged based on the number of generated tokens across all of the choices. Keep n as 1 to minimize costs. | 1
|
||||
| spring.ai.openai.chat.options.presencePenalty | Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. | -
|
||||
| spring.ai.openai.chat.options.responseFormat | An object 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.| -
|
||||
| spring.ai.openai.chat.options.responseFormat.type | Compatible with `GPT-4o`, `GPT-4o mini`, `GPT-4 Turbo` and all `GPT-3.5 Turbo` models newer than `gpt-3.5-turbo-1106`. The `JSON_OBJECT` type enables JSON mode, which guarantees the message the model generates is valid JSON.
|
||||
The `JSON_SCHEMA` type enables link:https://platform.openai.com/docs/guides/structured-outputs[Structured Outputs] which guarantees the model will match your supplied JSON schema. The JSON_SCHEMA type requires setting the `responseFormat.schema` property as well. | -
|
||||
| spring.ai.openai.chat.options.responseFormat.name | Reponse format schema name. Applicable only for `responseFormat.type=JSON_SCHEMA` | custom_response_format_schema
|
||||
| spring.ai.openai.chat.options.responseFormat.schema | Reponse format JSON schema. Applicable only for `responseFormat.type=JSON_SCHEMA` | -
|
||||
| spring.ai.openai.chat.options.responseFormat.strict | Reponse format JSON schema adheranse strictens. Applicable only for `responseFormat.type=JSON_SCHEMA` | -
|
||||
| spring.ai.openai.chat.options.seed | This feature is in Beta. If specified, our system will make a best effort to sample deterministically, such that repeated requests with the same seed and parameters should return the same result. | -
|
||||
| spring.ai.openai.chat.options.stop | Up to 4 sequences where the API will stop generating further tokens. | -
|
||||
| spring.ai.openai.chat.options.topP | An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both. | -
|
||||
@@ -155,7 +159,7 @@ Read more about xref:api/chat/functions/openai-chat-functions.adoc[OpenAI Functi
|
||||
== Multimodal
|
||||
|
||||
Multimodality refers to a model's ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats.
|
||||
Presently, the OpenAI `gpt-4-visual-preview` and `gpt-4o` models offers multimodal support.
|
||||
Presently, the OpenAI `gpt-4o` and `gpt-4o-mini` models offers multimodal support.
|
||||
Refer to the link:https://platform.openai.com/docs/guides/vision[Vision] guide for more information.
|
||||
|
||||
The OpenAI link:https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages[User Message API] can incorporate a list of base64-encoded images or image urls with the message.
|
||||
@@ -206,6 +210,108 @@ for carrying. The bowl is placed on a flat surface with a neutral-colored backgr
|
||||
view of the fruit inside.
|
||||
----
|
||||
|
||||
== Structured Output
|
||||
|
||||
In addition to existing, model agnostic, xref::api/structured-output-converter.adoc[Structured Output Converter] utilities,
|
||||
OpenAi provides custom https://platform.openai.com/docs/guides/structured-outputs[Structured Outputs] API that guarantees
|
||||
the model will always generate responses that adhere to your supplied https://json-schema.org/overview/what-is-jsonschema[JSON Schema].
|
||||
|
||||
Spring AI offers flexible options to configure your response-format either manually using the OpenAiChatOptions builder or using application properties.
|
||||
|
||||
=== with chat options builder
|
||||
|
||||
The OpenAiChatOptions builder allow you to set the response format programmatically like this:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
|
||||
var jsonSchema = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": { "type": "string" },
|
||||
"output": { "type": "string" }
|
||||
},
|
||||
"required": ["explanation", "output"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"final_answer": { "type": "string" }
|
||||
},
|
||||
"required": ["steps", "final_answer"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
""";
|
||||
|
||||
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
|
||||
OpenAiChatOptions.builder()
|
||||
.withModel(ChatModel.GPT_4_O_MINI)
|
||||
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, jsonSchema))
|
||||
.build());
|
||||
|
||||
ChatResponse response = this.openAiChatModel.call(prompt);
|
||||
|
||||
----
|
||||
|
||||
You can combine this with the existing xref::api/structured-output-converter.adoc#_bean_output_converter[BeanOutputConverter] utilities to
|
||||
generate the JSON schema from your domain objects and convert the response into domain instances:
|
||||
|
||||
[source,java]
|
||||
----
|
||||
record MathReasoning(
|
||||
@JsonProperty(required = true, value = "steps") Steps steps,
|
||||
@JsonProperty(required = true, value = "final_answer") String finalAnswer) {
|
||||
|
||||
record Steps(
|
||||
@JsonProperty(required = true, value = "items") Items[] items) {
|
||||
|
||||
record Items(
|
||||
@JsonProperty(required = true, value = "explanation") String explanation,
|
||||
@JsonProperty(required = true, value = "output") String output) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var outputConverter = new BeanOutputConverter<>(MathReasoning.class);
|
||||
|
||||
var jsonSchema = outputConverter.getJsonSchema();
|
||||
|
||||
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
|
||||
OpenAiChatOptions.builder()
|
||||
.withModel(ChatModel.GPT_4_O_MINI)
|
||||
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, jsonSchema))
|
||||
.build());
|
||||
|
||||
ChatResponse response = this.openAiChatModel.call(prompt);
|
||||
String content = response.getResult().getOutput().getContent();
|
||||
|
||||
MathReasoning mathReasoning = outputConverter.convert(content);
|
||||
----
|
||||
|
||||
NOTE: You must use the `@JsonProperty(required = true,..)` annotation to ensure the generated schema produces the `required[...]` field.
|
||||
Although optional for JSON Schema, OpenAI requires this filed for the structured response to work.
|
||||
|
||||
=== with application properties
|
||||
|
||||
You can use the `spring.ai.openai.chat.options.response-format.*` application properties to configure your desired response format.
|
||||
|
||||
[source,application.properties]
|
||||
----
|
||||
spring.ai.openai.api-key=YOUR_API_KEY
|
||||
spring.ai.openai.chat.options.model=gpt-4o-mini
|
||||
|
||||
spring.ai.openai.chat.options.response-format.type=JSON_SCHEMA
|
||||
spring.ai.openai.chat.options.response-format.name=MySchemaName
|
||||
spring.ai.openai.chat.options.response-format.schema=`{"type":"object","properties":{"steps":{"type":"array","items":{"type":"object","properties":{"explanation":{"type":"string"},"output":{"type":"string"}},"required":["explanation","output"],"additionalProperties":false}},"final_answer":{"type":"string"}},"required":["steps","final_answer"],"additionalProperties":false}`
|
||||
spring.ai.openai.chat.options.response-format.strict=true
|
||||
|
||||
----
|
||||
|
||||
== Sample Controller
|
||||
|
||||
https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-openai-spring-boot-starter` to your pom (or gradle) dependencies.
|
||||
|
||||
@@ -359,7 +359,6 @@ public class OpenAiPropertiesTests {
|
||||
"spring.ai.openai.chat.options.maxTokens=123",
|
||||
"spring.ai.openai.chat.options.n=10",
|
||||
"spring.ai.openai.chat.options.presencePenalty=0",
|
||||
"spring.ai.openai.chat.options.responseFormat.type=json",
|
||||
"spring.ai.openai.chat.options.seed=66",
|
||||
"spring.ai.openai.chat.options.stop=boza,koza",
|
||||
"spring.ai.openai.chat.options.temperature=0.55",
|
||||
@@ -414,7 +413,6 @@ public class OpenAiPropertiesTests {
|
||||
assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123);
|
||||
assertThat(chatProperties.getOptions().getN()).isEqualTo(10);
|
||||
assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0);
|
||||
assertThat(chatProperties.getOptions().getResponseFormat()).isEqualTo(new ResponseFormat("json"));
|
||||
assertThat(chatProperties.getOptions().getSeed()).isEqualTo(66);
|
||||
assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza");
|
||||
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright 2024 - 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.autoconfigure.openai;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.openai.OpenAiAudioSpeechModel;
|
||||
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||
import org.springframework.ai.openai.OpenAiImageModel;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ResponseFormat;
|
||||
import org.springframework.ai.openai.api.OpenAiAudioApi;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* Unit Tests for {@link OpenAiChatProperties} #options#responseFormat support.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public class OpenAiResponseFormatPropertiesTests {
|
||||
|
||||
@Test
|
||||
public void responseFormatJsonSchema() {
|
||||
|
||||
String responseFormatJsonSchema = """
|
||||
{
|
||||
"$schema" : "https://json-schema.org/draft/2020-12/schema",
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"someString" : {
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties" : false
|
||||
}
|
||||
""";
|
||||
|
||||
new ApplicationContextRunner().withPropertyValues(
|
||||
// @formatter:off
|
||||
"spring.ai.openai.api-key=API_KEY",
|
||||
|
||||
"spring.ai.openai.chat.options.response-format.type=JSON_SCHEMA",
|
||||
"spring.ai.openai.chat.options.response-format.name=MyName",
|
||||
"spring.ai.openai.chat.options.response-format.schema=" + responseFormatJsonSchema,
|
||||
"spring.ai.openai.chat.options.response-format.strict=true"
|
||||
)
|
||||
// @formatter:on
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var chatProperties = context.getBean(OpenAiChatProperties.class);
|
||||
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
|
||||
|
||||
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
|
||||
|
||||
assertThat(chatProperties.getOptions().getResponseFormat()).isEqualTo(
|
||||
new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, "MyName", responseFormatJsonSchema, true));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void responseFormatJsonObject() {
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY",
|
||||
"spring.ai.openai.chat.options.response-format.type=JSON_OBJECT")
|
||||
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var chatProperties = context.getBean(OpenAiChatProperties.class);
|
||||
|
||||
assertThat(chatProperties.getOptions().getResponseFormat())
|
||||
.isEqualTo(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyResponseFormat() {
|
||||
|
||||
new ApplicationContextRunner().withPropertyValues("spring.ai.openai.api-key=API_KEY")
|
||||
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var chatProperties = context.getBean(OpenAiChatProperties.class);
|
||||
|
||||
assertThat(chatProperties.getOptions().getResponseFormat()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transcriptionOptionsTest() {
|
||||
|
||||
new ApplicationContextRunner().withPropertyValues(
|
||||
// @formatter:off
|
||||
"spring.ai.openai.api-key=API_KEY",
|
||||
"spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
|
||||
"spring.ai.openai.audio.transcription.options.model=MODEL_XYZ",
|
||||
"spring.ai.openai.audio.transcription.options.language=en",
|
||||
"spring.ai.openai.audio.transcription.options.prompt=Er, yes, I think so",
|
||||
"spring.ai.openai.audio.transcription.options.responseFormat=JSON",
|
||||
"spring.ai.openai.audio.transcription.options.temperature=0.55"
|
||||
)
|
||||
// @formatter:on
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var transcriptionProperties = context.getBean(OpenAiAudioTranscriptionProperties.class);
|
||||
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
|
||||
var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class);
|
||||
|
||||
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
|
||||
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
|
||||
|
||||
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("text-embedding-ada-002");
|
||||
|
||||
assertThat(transcriptionProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
|
||||
assertThat(transcriptionProperties.getOptions().getLanguage()).isEqualTo("en");
|
||||
assertThat(transcriptionProperties.getOptions().getPrompt()).isEqualTo("Er, yes, I think so");
|
||||
assertThat(transcriptionProperties.getOptions().getResponseFormat())
|
||||
.isEqualTo(OpenAiAudioApi.TranscriptResponseFormat.JSON);
|
||||
assertThat(transcriptionProperties.getOptions().getTemperature()).isEqualTo(0.55f);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddingOptionsTest() {
|
||||
|
||||
new ApplicationContextRunner().withPropertyValues(
|
||||
// @formatter:off
|
||||
"spring.ai.openai.api-key=API_KEY",
|
||||
"spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
|
||||
"spring.ai.openai.embedding.options.model=MODEL_XYZ",
|
||||
"spring.ai.openai.embedding.options.encodingFormat=MyEncodingFormat",
|
||||
"spring.ai.openai.embedding.options.user=userXYZ"
|
||||
)
|
||||
// @formatter:on
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
|
||||
var embeddingProperties = context.getBean(OpenAiEmbeddingProperties.class);
|
||||
|
||||
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
|
||||
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
|
||||
|
||||
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
|
||||
assertThat(embeddingProperties.getOptions().getEncodingFormat()).isEqualTo("MyEncodingFormat");
|
||||
assertThat(embeddingProperties.getOptions().getUser()).isEqualTo("userXYZ");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void imageOptionsTest() {
|
||||
new ApplicationContextRunner().withPropertyValues(
|
||||
// @formatter:off
|
||||
"spring.ai.openai.api-key=API_KEY",
|
||||
"spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
|
||||
"spring.ai.openai.image.options.n=3",
|
||||
"spring.ai.openai.image.options.model=MODEL_XYZ",
|
||||
"spring.ai.openai.image.options.quality=hd",
|
||||
"spring.ai.openai.image.options.response_format=url",
|
||||
"spring.ai.openai.image.options.size=1024x1024",
|
||||
"spring.ai.openai.image.options.width=1024",
|
||||
"spring.ai.openai.image.options.height=1024",
|
||||
"spring.ai.openai.image.options.style=vivid",
|
||||
"spring.ai.openai.image.options.user=userXYZ"
|
||||
)
|
||||
// @formatter:on
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
var imageProperties = context.getBean(OpenAiImageProperties.class);
|
||||
var connectionProperties = context.getBean(OpenAiConnectionProperties.class);
|
||||
|
||||
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
|
||||
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
|
||||
|
||||
assertThat(imageProperties.getOptions().getN()).isEqualTo(3);
|
||||
assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
|
||||
assertThat(imageProperties.getOptions().getQuality()).isEqualTo("hd");
|
||||
assertThat(imageProperties.getOptions().getResponseFormat()).isEqualTo("url");
|
||||
assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024");
|
||||
assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024);
|
||||
assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024);
|
||||
assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid");
|
||||
assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void embeddingActivation() {
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.embedding.enabled=false")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.embedding.enabled=true")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiEmbeddingModel.class)).isNotEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatActivation() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.chat.enabled=false")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiChatModel.class)).isEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.chat.enabled=true")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiChatProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiChatModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageActivation() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.image.enabled=false")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.image.enabled=true")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiImageProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiImageModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void audioSpeechActivation() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.audio.speech.enabled=false")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.audio.speech.enabled=true")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioSpeechModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void audioTranscriptionActivation() {
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.audio.transcription.enabled=false")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
new ApplicationContextRunner()
|
||||
.withPropertyValues("spring.ai.openai.api-key=API_KEY", "spring.ai.openai.base-url=TEST_BASE_URL",
|
||||
"spring.ai.openai.audio.transcription.enabled=true")
|
||||
.withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionProperties.class)).isNotEmpty();
|
||||
assertThat(context.getBeansOfType(OpenAiAudioTranscriptionModel.class)).isNotEmpty();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user