Support Ollama JSON Structured Output

Ollama has recently introduced native support for JSON structured output, as described in https://ollama.com/blog/structured-outputs.
This PR introduces support for it, both for directly passing a JSON schema and when using the Spring AI output conversion APIs.

Signed-off-by: Thomas Vitale <ThomasVitale@users.noreply.github.com>
This commit is contained in:
Thomas Vitale
2024-12-15 07:46:49 +04:00
committed by Christian Tzolov
parent be9d6a10fa
commit 6ab7e20616
8 changed files with 133 additions and 12 deletions

View File

@@ -25,11 +25,9 @@ import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonFormat.Feature;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
@@ -418,7 +416,7 @@ public class OllamaApi {
* @param model The model to use for completion. It should be a name familiar to Ollama from the <a href="https://ollama.com/library">Library</a>.
* @param messages The list of messages in the chat. This can be used to keep a chat memory.
* @param stream Whether to stream the response. If false, the response will be returned as a single response object rather than a stream of objects.
* @param format The format to return the response in. Currently, the only accepted value is "json".
* @param format The format to return the response in. It can either be the String "json" or a Map containing a JSON Schema definition.
* @param keepAlive Controls how long the model will stay loaded into memory following this request (default: 5m).
* @param tools List of tools the model has access to.
* @param options Model-specific options. For example, "temperature" can be set through this field, if the model supports it.
@@ -435,7 +433,7 @@ public class OllamaApi {
@JsonProperty("model") String model,
@JsonProperty("messages") List<Message> messages,
@JsonProperty("stream") Boolean stream,
@JsonProperty("format") String format,
@JsonProperty("format") Object format,
@JsonProperty("keep_alive") String keepAlive,
@JsonProperty("tools") List<Tool> tools,
@JsonProperty("options") Map<String, Object> options
@@ -507,7 +505,7 @@ public class OllamaApi {
private final String model;
private List<Message> messages = List.of();
private boolean stream = false;
private String format;
private Object format;
private String keepAlive;
private List<Tool> tools = List.of();
private Map<String, Object> options = Map.of();
@@ -527,7 +525,7 @@ public class OllamaApi {
return this;
}
public Builder withFormat(String format) {
public Builder withFormat(Object format) {
this.format = format;
return this;
}

View File

@@ -287,7 +287,7 @@ public class OllamaOptions implements FunctionCallingOptions, EmbeddingOptions {
* Part of Chat completion <a href="https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1">advanced parameters</a>.
*/
@JsonProperty("format")
private String format;
private Object format;
/**
* Sets the length of time for Ollama to keep the model loaded. Valid values for this
@@ -411,7 +411,7 @@ public class OllamaOptions implements FunctionCallingOptions, EmbeddingOptions {
return this;
}
public OllamaOptions withFormat(String format) {
public OllamaOptions withFormat(Object format) {
this.format = format;
return this;
}
@@ -614,11 +614,11 @@ public class OllamaOptions implements FunctionCallingOptions, EmbeddingOptions {
this.model = model;
}
public String getFormat() {
public Object getFormat() {
return this.format;
}
public void setFormat(String format) {
public void setFormat(Object format) {
this.format = format;
}

View File

@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
@@ -228,6 +229,31 @@ class OllamaChatModelIT extends BaseOllamaIT {
assertThat(actorsFilms.movies()).hasSize(5);
}
// Example inspired by https://ollama.com/blog/structured-outputs
@Test
void jsonSchemaFormatStructuredOutput() {
var outputConverter = new BeanOutputConverter<>(CountryInfo.class);
var userPromptTemplate = new PromptTemplate("""
Tell me about {country}.
""");
Map<String, Object> model = Map.of("country", "denmark");
var prompt = userPromptTemplate.create(model,
OllamaOptions.builder()
.withModel(OllamaModel.LLAMA3_2.getName())
.withFormat(outputConverter.getJsonSchemaMap())
.build());
var chatResponse = this.chatModel.call(prompt);
var countryInfo = outputConverter.convert(chatResponse.getResult().getOutput().getText());
assertThat(countryInfo).isNotNull();
assertThat(countryInfo.capital()).isEqualToIgnoringCase("Copenhagen");
}
record CountryInfo(@JsonProperty(required = true) String name, @JsonProperty(required = true) String capital,
@JsonProperty(required = true) List<String> languages) {
}
record ActorsFilmsRecord(String actor, List<String> movies) {
}

View File

@@ -68,7 +68,7 @@ class OllamaChatModelMultimodalIT extends BaseOllamaIT {
var response = this.chatModel.call(new Prompt(List.of(userMessage)));
logger.info(response.getResult().getOutput().getText());
assertThat(response.getResult().getOutput().getText()).contains("bananas", "apple");
assertThat(response.getResult().getOutput().getText()).containsAnyOf("bananas", "apple");
}
@SpringBootConfiguration

View File

@@ -23,7 +23,7 @@ import org.testcontainers.utility.DockerImageName;
*/
public final class OllamaImage {
public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("ollama/ollama:0.5.1");
public static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("ollama/ollama:0.5.2");
private OllamaImage() {

View File

@@ -99,6 +99,7 @@ class OllamaModelManagerIT extends BaseOllamaIT {
assertThat(isModelWithLatestVersionAvailable).isFalse();
}
@Disabled
@Test
public void pullAndDeleteModelFromHuggingFace() {
// Pull model with explicit version.

View File

@@ -17,6 +17,7 @@
package org.springframework.ai.converter;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.Objects;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -54,6 +55,7 @@ import org.springframework.lang.NonNull;
* @author Josh Long
* @author Sebastien Deleuze
* @author Soby Chacko
* @author Thomas Vitale
*/
public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
@@ -220,4 +222,14 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
return this.jsonSchema;
}
public Map<String, Object> getJsonSchemaMap() {
try {
return this.objectMapper.readValue(this.jsonSchema, Map.class);
}
catch (JsonProcessingException ex) {
logger.error("Could not parse the JSON Schema to a Map object", ex);
throw new IllegalStateException(ex);
}
}
}

View File

@@ -269,6 +269,90 @@ photo was taken in an area with metallic decorations or fixtures. The overall se
where fruits are being displayed, possibly for convenience or aesthetic purposes.
----
== Structured Outputs
Ollama provides custom https://ollama.com/blog/structured-outputs[Structured Outputs] APIs that ensure your model generates responses conforming strictly to your provided `JSON Schema`.
In addition to the existing Spring AI model-agnostic xref::api/structured-output-converter.adoc[Structured Output Converter], these APIs offer enhanced control and precision.
=== Configuration
Spring AI allows you to configure your response format programmatically using the `OllamaOptions` builder.
==== Using the Chat Options Builder
You can set the response format programmatically with the `OllamaOptions` builder as shown below:
[source,java]
----
String 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",
OllamaOptions.builder()
.withModel(OllamaModel.LLAMA3_2.getName())
.withFormat(new ObjectMapper().readValue(jsonSchema, Map.class))
.build());
ChatResponse response = this.ollamaChatModel.call(this.prompt);
----
==== Integrating with BeanOutputConverter Utilities
You can leverage existing xref::api/structured-output-converter.adoc#_bean_output_converter[BeanOutputConverter] utilities to automatically generate the JSON Schema from your domain objects and later convert the structured response into domain-specific 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);
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
OllamaOptions.builder()
.withModel(OllamaModel.LLAMA3_2.getName())
.withFormat(outputConverter.getJsonSchemaMap())
.build());
ChatResponse response = this.ollamaChatModel.call(this.prompt);
String content = this.response.getResult().getOutput().getText();
MathReasoning mathReasoning = this.outputConverter.convert(this.content);
----
NOTE: Ensure you use the `@JsonProperty(required = true,...)` annotation for generating a schema that accurately marks fields as `required`.
Although this is optional for JSON Schema, it's recommended for the structured response to function correctly.
== OpenAI API Compatibility
Ollama is OpenAI API-compatible and you can use the xref:api/chat/openai-chat.adoc[Spring AI OpenAI] client to talk to Ollama and use tools.