diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java index f5b559d654b..21530abb8c1 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java @@ -33,6 +33,8 @@ import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat; import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ToolChoice; import org.springframework.ai.mistralai.api.MistralAiApi.FunctionTool; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; @@ -49,7 +51,7 @@ * @since 0.8.1 */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class MistralAiChatOptions implements ToolCallingChatOptions { +public class MistralAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions { /** * ID of the model to use @@ -367,6 +369,22 @@ public void setToolContext(Map toolContext) { this.toolContext = toolContext; } + @Override + @JsonIgnore + public String getOutputSchema() { + if (this.responseFormat == null || this.responseFormat.getJsonSchema() == null) { + return null; + } + return ModelOptionsUtils.toJsonString(this.responseFormat.getJsonSchema().getSchema()); + } + + @Override + @JsonIgnore + public void setOutputSchema(String outputSchema) { + this.setResponseFormat( + ResponseFormat.builder().type(ResponseFormat.Type.JSON_SCHEMA).jsonSchema(outputSchema).build()); + } + @Override @SuppressWarnings("unchecked") public MistralAiChatOptions copy() { @@ -518,6 +536,11 @@ public Builder toolContext(Map toolContext) { return this; } + public Builder outputSchema(String outputSchema) { + this.options.setOutputSchema(outputSchema); + return this; + } + public MistralAiChatOptions build() { return this.options; } diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java index e55987e91f2..0bc95762f92 100644 --- a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java @@ -745,16 +745,342 @@ public enum ToolChoice { /** * An object specifying the format that the model must output. * - * @param type Must be one of 'text', 'json_object' or 'json_schema'. - * @param jsonSchema A specific JSON schema to match, if 'type' is 'json_schema'. + *

+ * Setting the type to JSON_SCHEMA enables Structured Outputs which ensures the + * model will match your supplied JSON schema. + *

+ * + * @author Ricken Bazolo + * @author Christian Tzolov + * @see Mistral + * AI Structured Output */ @JsonInclude(Include.NON_NULL) - public record ResponseFormat(@JsonProperty("type") String type, - @JsonProperty("json_schema") Map jsonSchema) { + public static class ResponseFormat { + + /** + * Type Must be one of 'text', 'json_object' or 'json_schema'. + */ + @JsonProperty("type") + private Type type; + + /** + * JSON schema object that describes the format of the JSON object. Only + * applicable when type is 'json_schema'. + */ + @JsonProperty("json_schema") + private JsonSchema jsonSchema = null; + @JsonIgnore + private String schema; + + public ResponseFormat() { + } + + /** + * @deprecated Use {@link #builder()} or factory methods instead. + */ + @Deprecated public ResponseFormat(String type) { - this(type, null); + this(Type.fromValue(type), (JsonSchema) null); + } + + /** + * @deprecated Use {@link #builder()} or factory methods instead. + */ + @Deprecated + public ResponseFormat(String type, Map jsonSchema) { + this(Type.fromValue(type), + jsonSchema != null ? JsonSchema.builder().schema(jsonSchema).strict(true).build() : null); + } + + private ResponseFormat(Type type, JsonSchema jsonSchema) { + this.type = type; + this.jsonSchema = jsonSchema; + } + + public ResponseFormat(Type type, String schema) { + this(type, org.springframework.util.StringUtils.hasText(schema) + ? JsonSchema.builder().schema(schema).strict(true).build() : null); + } + + public Type getType() { + return this.type; + } + + public void setType(Type type) { + this.type = type; + } + + public JsonSchema getJsonSchema() { + return this.jsonSchema; + } + + public void setJsonSchema(JsonSchema jsonSchema) { + this.jsonSchema = jsonSchema; + } + + public String getSchema() { + return this.schema; + } + + public void setSchema(String schema) { + this.schema = schema; + if (schema != null) { + this.jsonSchema = JsonSchema.builder().schema(schema).strict(true).build(); + } + } + + // Factory methods + + /** + * Creates a ResponseFormat for text output. + * @return ResponseFormat configured for text output + */ + public static ResponseFormat text() { + return new ResponseFormat(Type.TEXT, (JsonSchema) null); } + + /** + * Creates a ResponseFormat for JSON object output (JSON mode). + * @return ResponseFormat configured for JSON object output + */ + public static ResponseFormat jsonObject() { + return new ResponseFormat(Type.JSON_OBJECT, (JsonSchema) null); + } + + /** + * Creates a ResponseFormat for JSON schema output with automatic schema + * generation from a class. + * @param clazz the class to generate the JSON schema from + * @return ResponseFormat configured with the generated JSON schema + */ + public static ResponseFormat jsonSchema(Class clazz) { + String schemaJson = org.springframework.ai.util.json.schema.JsonSchemaGenerator.generateForType(clazz); + return jsonSchema(schemaJson); + } + + /** + * Creates a ResponseFormat for JSON schema output with a JSON schema string. + * @param schema the JSON schema as a string + * @return ResponseFormat configured with the provided JSON schema + */ + public static ResponseFormat jsonSchema(String schema) { + return new ResponseFormat(Type.JSON_SCHEMA, JsonSchema.builder().schema(schema).strict(true).build()); + } + + /** + * Creates a ResponseFormat for JSON schema output with a JSON schema map. + * @param schema the JSON schema as a map + * @return ResponseFormat configured with the provided JSON schema + */ + public static ResponseFormat jsonSchema(Map schema) { + return new ResponseFormat(Type.JSON_SCHEMA, JsonSchema.builder().schema(schema).strict(true).build()); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ResponseFormat that = (ResponseFormat) o; + return this.type == that.type && Objects.equals(this.jsonSchema, that.jsonSchema); + } + + @Override + public int hashCode() { + return Objects.hash(this.type, this.jsonSchema); + } + + @Override + public String toString() { + return "ResponseFormat{" + "type=" + this.type + ", jsonSchema=" + this.jsonSchema + '}'; + } + + public static final class Builder { + + private Type type; + + private JsonSchema jsonSchema; + + private Builder() { + } + + public Builder type(Type type) { + this.type = type; + return this; + } + + public Builder jsonSchema(JsonSchema jsonSchema) { + this.jsonSchema = jsonSchema; + return this; + } + + public Builder jsonSchema(String jsonSchema) { + this.jsonSchema = JsonSchema.builder().schema(jsonSchema).build(); + return this; + } + + public ResponseFormat build() { + return new ResponseFormat(this.type, this.jsonSchema); + } + + } + + public enum Type { + + /** + * Generates a text response. (default) + */ + @JsonProperty("text") + TEXT("text"), + + /** + * Enables JSON mode, which guarantees the message the model generates is + * valid JSON. + */ + @JsonProperty("json_object") + JSON_OBJECT("json_object"), + + /** + * Enables Structured Outputs which guarantees the model will match your + * supplied JSON schema. + */ + @JsonProperty("json_schema") + JSON_SCHEMA("json_schema"); + + private final String value; + + Type(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + public static Type fromValue(String value) { + for (Type type : Type.values()) { + if (type.value.equals(value)) { + return type; + } + } + throw new IllegalArgumentException("Unknown ResponseFormat type: " + value); + } + + } + + /** + * JSON schema object that describes the format of the JSON object. Applicable + * for the 'json_schema' type only. + */ + @JsonInclude(Include.NON_NULL) + public static class JsonSchema { + + @JsonProperty("name") + private String name; + + @JsonProperty("schema") + private Map schema; + + @JsonProperty("strict") + private Boolean strict; + + public JsonSchema() { + } + + public String getName() { + return this.name; + } + + public Map getSchema() { + return this.schema; + } + + public Boolean getStrict() { + return this.strict; + } + + private JsonSchema(String name, Map schema, Boolean strict) { + this.name = name; + this.schema = schema; + this.strict = strict; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public int hashCode() { + return Objects.hash(this.name, this.schema, this.strict); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JsonSchema that = (JsonSchema) o; + return Objects.equals(this.name, that.name) && Objects.equals(this.schema, that.schema) + && Objects.equals(this.strict, that.strict); + } + + @Override + public String toString() { + return "JsonSchema{" + "name='" + this.name + '\'' + ", schema=" + this.schema + ", strict=" + + this.strict + '}'; + } + + public static final class Builder { + + private String name = "custom_schema"; + + private Map schema; + + private Boolean strict = true; + + private Builder() { + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder schema(Map schema) { + this.schema = schema; + return this; + } + + public Builder schema(String schema) { + this.schema = ModelOptionsUtils.jsonToMap(schema); + return this; + } + + public Builder strict(Boolean strict) { + this.strict = strict; + return this; + } + + public JsonSchema build() { + return new JsonSchema(this.name, this.schema, this.strict); + } + + } + + } + } } diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatModelIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatModelIT.java index 38112b1e992..8d7a7c93d77 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatModelIT.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatModelIT.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -33,6 +34,13 @@ import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; +import org.springframework.ai.chat.client.AdvisorParams; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.CallAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -52,6 +60,7 @@ import org.springframework.ai.converter.ListOutputConverter; import org.springframework.ai.converter.MapOutputConverter; import org.springframework.ai.mistralai.api.MistralAiApi; +import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat; import org.springframework.ai.model.tool.DefaultToolCallingManager; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; @@ -434,4 +443,125 @@ record ActorsFilmsRecord(String actor, List movies) { } + @Test + void structuredOutputWithJsonSchema() { + // Test using ResponseFormat.jsonSchema(Class) for structured output + + var promptOptions = MistralAiChatOptions.builder() + .model(MistralAiApi.ChatModel.SMALL.getValue()) + .responseFormat(ResponseFormat.jsonSchema(MovieRecommendation.class)) + .build(); + + UserMessage userMessage = new UserMessage( + "Recommend a classic science fiction movie. Provide the title, director, release year, and a brief plot summary."); + + ChatResponse response = this.chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response.getResult().getOutput().getText()); + + String content = response.getResult().getOutput().getText(); + assertThat(content).isNotNull(); + assertThat(content).contains("title"); + assertThat(content).contains("director"); + assertThat(content).contains("year"); + assertThat(content).contains("plotSummary"); + + // Verify the response can be parsed as the expected record + BeanOutputConverter outputConverter = new BeanOutputConverter<>(MovieRecommendation.class); + MovieRecommendation movie = outputConverter.convert(content); + + assertThat(movie).isNotNull(); + assertThat(movie.title()).isNotBlank(); + assertThat(movie.director()).isNotBlank(); + assertThat(movie.year()).isGreaterThan(1900); + assertThat(movie.plotSummary()).isNotBlank(); + + logger.info("Parsed movie: {}", movie); + } + + @Test + void structuredOutputWithJsonSchemaFromMap() { + // Test using ResponseFormat.jsonSchema(Map) for structured output + + Map schema = Map.of("type", "object", "properties", + Map.of("city", Map.of("type", "string"), "country", Map.of("type", "string"), "population", + Map.of("type", "integer"), "famousFor", Map.of("type", "string")), + "required", List.of("city", "country", "population", "famousFor"), "additionalProperties", false); + + var promptOptions = MistralAiChatOptions.builder() + .model(MistralAiApi.ChatModel.SMALL.getValue()) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + + UserMessage userMessage = new UserMessage( + "Tell me about Paris, France. Include the city name, country, approximate population, and what it is famous for."); + + ChatResponse response = this.chatModel.call(new Prompt(List.of(userMessage), promptOptions)); + + logger.info("Response: {}", response.getResult().getOutput().getText()); + + String content = response.getResult().getOutput().getText(); + assertThat(content).isNotNull(); + assertThat(content).containsIgnoringCase("Paris"); + assertThat(content).containsIgnoringCase("France"); + } + + @Test + void chatClientEntityWithStructuredOutput() { + // Test using ChatClient high-level API with .entity(Class) method + // This verifies that StructuredOutputChatOptions implementation works correctly + // with ChatClient + + ChatClient chatClient = ChatClient.builder(this.chatModel).build(); + + // Advisor to verify that native structured output is being used + AtomicBoolean nativeStructuredOutputUsed = new AtomicBoolean(false); + CallAdvisor verifyNativeStructuredOutputAdvisor = new CallAdvisor() { + @Override + public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { + if (request.prompt().getOptions() instanceof MistralAiChatOptions options) { + ResponseFormat responseFormat = options.getResponseFormat(); + if (responseFormat != null && responseFormat.getType() == ResponseFormat.Type.JSON_SCHEMA) { + nativeStructuredOutputUsed.set(true); + logger.info("Native structured output verified - ResponseFormat type: {}", + responseFormat.getType()); + } + } + return chain.nextCall(request); + } + + @Override + public String getName() { + return "VerifyNativeStructuredOutputAdvisor"; + } + + @Override + public int getOrder() { + return 0; + } + }; + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + // forces native structured output handling via StructuredOutputChatOptions + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .advisors(verifyNativeStructuredOutputAdvisor) + .call() + .entity(ActorsFilmsRecord.class); + + logger.info("ChatClient entity result: {}", actorsFilms); + + // Verify that native structured output was used + assertThat(nativeStructuredOutputUsed.get()) + .as("Native structured output should be used with ResponseFormat.Type.JSON_SCHEMA") + .isTrue(); + + assertThat(actorsFilms).isNotNull(); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + record MovieRecommendation(String title, String director, int year, String plotSummary) { + + } + } diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatOptionsTests.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatOptionsTests.java index 22f4b36d5e4..30a73682493 100644 --- a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatOptionsTests.java +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatOptionsTests.java @@ -20,12 +20,16 @@ import java.util.List; import java.util.Map; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.ai.mistralai.api.MistralAiApi; import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Tests for {@link MistralAiChatOptions}. @@ -279,4 +283,265 @@ void testBuilderAndSetterConsistency() { assertThat(builderOptions).isEqualTo(setterOptions); } + // Tests for ResponseFormat factory methods and structured output support + + @Test + void testResponseFormatTextFactory() { + ResponseFormat textFormat = ResponseFormat.text(); + + assertThat(textFormat.getType()).isEqualTo(ResponseFormat.Type.TEXT); + assertThat(textFormat.getJsonSchema()).isNull(); + } + + @Test + void testResponseFormatJsonObjectFactory() { + ResponseFormat jsonObjectFormat = ResponseFormat.jsonObject(); + + assertThat(jsonObjectFormat.getType()).isEqualTo(ResponseFormat.Type.JSON_OBJECT); + assertThat(jsonObjectFormat.getJsonSchema()).isNull(); + } + + @Test + void testResponseFormatJsonSchemaFromString() { + String schema = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}"; + ResponseFormat jsonSchemaFormat = ResponseFormat.jsonSchema(schema); + + assertThat(jsonSchemaFormat.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(jsonSchemaFormat.getJsonSchema()).isNotNull(); + assertThat(jsonSchemaFormat.getJsonSchema().getName()).isEqualTo("custom_schema"); + assertThat(jsonSchemaFormat.getJsonSchema().getStrict()).isTrue(); + assertThat(jsonSchemaFormat.getJsonSchema().getSchema()).containsKey("type"); + assertThat(jsonSchemaFormat.getJsonSchema().getSchema().get("type")).isEqualTo("object"); + } + + @Test + void testResponseFormatJsonSchemaFromMap() { + Map schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + ResponseFormat jsonSchemaFormat = ResponseFormat.jsonSchema(schema); + + assertThat(jsonSchemaFormat.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(jsonSchemaFormat.getJsonSchema()).isNotNull(); + assertThat(jsonSchemaFormat.getJsonSchema().getName()).isEqualTo("custom_schema"); + assertThat(jsonSchemaFormat.getJsonSchema().getStrict()).isTrue(); + assertThat(jsonSchemaFormat.getJsonSchema().getSchema()).isEqualTo(schema); + } + + @Test + void testResponseFormatJsonSchemaFromClass() { + ResponseFormat jsonSchemaFormat = ResponseFormat.jsonSchema(TestRecord.class); + + assertThat(jsonSchemaFormat.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(jsonSchemaFormat.getJsonSchema()).isNotNull(); + assertThat(jsonSchemaFormat.getJsonSchema().getName()).isEqualTo("custom_schema"); + assertThat(jsonSchemaFormat.getJsonSchema().getStrict()).isTrue(); + assertThat(jsonSchemaFormat.getJsonSchema().getSchema()).containsKey("type"); + assertThat(jsonSchemaFormat.getJsonSchema().getSchema()).containsKey("properties"); + } + + @Test + void testResponseFormatBuilder() { + ResponseFormat.JsonSchema jsonSchema = ResponseFormat.JsonSchema.builder() + .name("my_schema") + .schema(Map.of("type", "object")) + .strict(false) + .build(); + + ResponseFormat format = ResponseFormat.builder() + .type(ResponseFormat.Type.JSON_SCHEMA) + .jsonSchema(jsonSchema) + .build(); + + assertThat(format.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(format.getJsonSchema().getName()).isEqualTo("my_schema"); + assertThat(format.getJsonSchema().getStrict()).isFalse(); + } + + @Test + void testResponseFormatBuilderWithStringSchema() { + String schema = "{\"type\":\"object\",\"properties\":{}}"; + ResponseFormat format = ResponseFormat.builder() + .type(ResponseFormat.Type.JSON_SCHEMA) + .jsonSchema(schema) + .build(); + + assertThat(format.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(format.getJsonSchema()).isNotNull(); + assertThat(format.getJsonSchema().getSchema()).containsKey("type"); + } + + @Test + void testBackwardCompatibilityDeprecatedConstructors() { + // Test deprecated constructor with type string + @SuppressWarnings("deprecation") + ResponseFormat textFormat = new ResponseFormat("text"); + assertThat(textFormat.getType()).isEqualTo(ResponseFormat.Type.TEXT); + + // Test deprecated constructor with type and schema map + Map schemaMap = Map.of("type", "object"); + @SuppressWarnings("deprecation") + ResponseFormat jsonSchemaFormat = new ResponseFormat("json_schema", schemaMap); + assertThat(jsonSchemaFormat.getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(jsonSchemaFormat.getJsonSchema()).isNotNull(); + } + + @Test + void testResponseFormatTypeFromValue() { + assertThat(ResponseFormat.Type.fromValue("text")).isEqualTo(ResponseFormat.Type.TEXT); + assertThat(ResponseFormat.Type.fromValue("json_object")).isEqualTo(ResponseFormat.Type.JSON_OBJECT); + assertThat(ResponseFormat.Type.fromValue("json_schema")).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + } + + @Test + void testResponseFormatTypeFromValueInvalid() { + assertThatThrownBy(() -> ResponseFormat.Type.fromValue("invalid")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown ResponseFormat type"); + } + + @Test + void testStructuredOutputChatOptionsInterface() { + // Verify that MistralAiChatOptions implements StructuredOutputChatOptions + MistralAiChatOptions options = new MistralAiChatOptions(); + assertThat(options).isInstanceOf(StructuredOutputChatOptions.class); + } + + @Test + void testGetOutputSchemaReturnsNullWhenNoResponseFormat() { + MistralAiChatOptions options = new MistralAiChatOptions(); + assertThat(options.getOutputSchema()).isNull(); + } + + @Test + void testGetOutputSchemaReturnsNullWhenNoJsonSchema() { + MistralAiChatOptions options = MistralAiChatOptions.builder().responseFormat(ResponseFormat.text()).build(); + assertThat(options.getOutputSchema()).isNull(); + } + + @Test + void testGetOutputSchemaReturnsSchemaAsString() { + Map schema = Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + MistralAiChatOptions options = MistralAiChatOptions.builder() + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + + String outputSchema = options.getOutputSchema(); + assertThat(outputSchema).isNotNull(); + assertThat(outputSchema).contains("\"type\""); + assertThat(outputSchema).contains("\"object\""); + } + + @Test + void testSetOutputSchema() { + MistralAiChatOptions options = new MistralAiChatOptions(); + String schema = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}"; + + options.setOutputSchema(schema); + + assertThat(options.getResponseFormat()).isNotNull(); + assertThat(options.getResponseFormat().getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + assertThat(options.getResponseFormat().getJsonSchema()).isNotNull(); + assertThat(options.getResponseFormat().getJsonSchema().getSchema()).containsKey("type"); + } + + @Test + void testBuilderOutputSchema() { + String schema = "{\"type\":\"object\",\"properties\":{}}"; + MistralAiChatOptions options = MistralAiChatOptions.builder().model("test-model").outputSchema(schema).build(); + + assertThat(options.getResponseFormat()).isNotNull(); + assertThat(options.getResponseFormat().getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + } + + @Test + void testJsonSerializationOfResponseFormat() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + + ResponseFormat format = ResponseFormat.jsonSchema(Map.of("type", "object")); + String json = objectMapper.writeValueAsString(format); + + assertThat(json).contains("\"type\":\"json_schema\""); + assertThat(json).contains("\"json_schema\""); + assertThat(json).contains("\"name\":\"custom_schema\""); + assertThat(json).contains("\"strict\":true"); + } + + @Test + void testResponseFormatEqualsAndHashCode() { + ResponseFormat format1 = ResponseFormat.jsonSchema(Map.of("type", "object")); + ResponseFormat format2 = ResponseFormat.jsonSchema(Map.of("type", "object")); + ResponseFormat format3 = ResponseFormat.text(); + + assertThat(format1).isEqualTo(format2); + assertThat(format1.hashCode()).isEqualTo(format2.hashCode()); + assertThat(format1).isNotEqualTo(format3); + } + + @Test + void testJsonSchemaEqualsAndHashCode() { + ResponseFormat.JsonSchema schema1 = ResponseFormat.JsonSchema.builder() + .name("test") + .schema(Map.of("type", "object")) + .strict(true) + .build(); + + ResponseFormat.JsonSchema schema2 = ResponseFormat.JsonSchema.builder() + .name("test") + .schema(Map.of("type", "object")) + .strict(true) + .build(); + + ResponseFormat.JsonSchema schema3 = ResponseFormat.JsonSchema.builder() + .name("different") + .schema(Map.of("type", "object")) + .strict(true) + .build(); + + assertThat(schema1).isEqualTo(schema2); + assertThat(schema1.hashCode()).isEqualTo(schema2.hashCode()); + assertThat(schema1).isNotEqualTo(schema3); + } + + @Test + void testResponseFormatToString() { + ResponseFormat format = ResponseFormat.jsonSchema(Map.of("type", "object")); + String toString = format.toString(); + + assertThat(toString).contains("ResponseFormat"); + assertThat(toString).contains("type=JSON_SCHEMA"); + assertThat(toString).contains("jsonSchema="); + } + + @Test + void testJsonSchemaToString() { + ResponseFormat.JsonSchema schema = ResponseFormat.JsonSchema.builder() + .name("test_schema") + .schema(Map.of("type", "object")) + .strict(true) + .build(); + + String toString = schema.toString(); + + assertThat(toString).contains("JsonSchema"); + assertThat(toString).contains("name='test_schema'"); + assertThat(toString).contains("strict=true"); + } + + @Test + void testResponseFormatWithOptionsIntegration() { + MistralAiChatOptions options = MistralAiChatOptions.builder() + .model("mistral-small-latest") + .temperature(0.7) + .responseFormat(ResponseFormat.jsonSchema(TestRecord.class)) + .build(); + + assertThat(options.getModel()).isEqualTo("mistral-small-latest"); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getResponseFormat()).isNotNull(); + assertThat(options.getResponseFormat().getType()).isEqualTo(ResponseFormat.Type.JSON_SCHEMA); + } + + // Test record for schema generation tests + record TestRecord(String name, int age, List tags) { + + } + }