Skip to content

Commit c806a6a

Browse files
committed
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 <[email protected]>
1 parent f69d879 commit c806a6a

File tree

5 files changed

+130
-10
lines changed

5 files changed

+130
-10
lines changed

models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaApi.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@
2525
import java.util.concurrent.atomic.AtomicBoolean;
2626
import java.util.function.Consumer;
2727

28-
import com.fasterxml.jackson.annotation.JsonFormat;
2928
import com.fasterxml.jackson.annotation.JsonInclude;
3029
import com.fasterxml.jackson.annotation.JsonInclude.Include;
3130
import com.fasterxml.jackson.annotation.JsonProperty;
32-
import com.fasterxml.jackson.annotation.JsonFormat.Feature;
3331
import org.apache.commons.logging.Log;
3432
import org.apache.commons.logging.LogFactory;
3533
import reactor.core.publisher.Flux;
@@ -418,7 +416,7 @@ public Message build() {
418416
* @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>.
419417
* @param messages The list of messages in the chat. This can be used to keep a chat memory.
420418
* @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.
421-
* @param format The format to return the response in. Currently, the only accepted value is "json".
419+
* @param format The format to return the response in. It can either be the String "json" or a Map containing a JSON Schema definition.
422420
* @param keepAlive Controls how long the model will stay loaded into memory following this request (default: 5m).
423421
* @param tools List of tools the model has access to.
424422
* @param options Model-specific options. For example, "temperature" can be set through this field, if the model supports it.
@@ -435,7 +433,7 @@ public record ChatRequest(
435433
@JsonProperty("model") String model,
436434
@JsonProperty("messages") List<Message> messages,
437435
@JsonProperty("stream") Boolean stream,
438-
@JsonProperty("format") String format,
436+
@JsonProperty("format") Object format,
439437
@JsonProperty("keep_alive") String keepAlive,
440438
@JsonProperty("tools") List<Tool> tools,
441439
@JsonProperty("options") Map<String, Object> options
@@ -507,7 +505,7 @@ public static class Builder {
507505
private final String model;
508506
private List<Message> messages = List.of();
509507
private boolean stream = false;
510-
private String format;
508+
private Object format;
511509
private String keepAlive;
512510
private List<Tool> tools = List.of();
513511
private Map<String, Object> options = Map.of();
@@ -527,7 +525,7 @@ public Builder withStream(boolean stream) {
527525
return this;
528526
}
529527

530-
public Builder withFormat(String format) {
528+
public Builder withFormat(Object format) {
531529
this.format = format;
532530
return this;
533531
}

models/spring-ai-ollama/src/main/java/org/springframework/ai/ollama/api/OllamaOptions.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ public class OllamaOptions implements FunctionCallingOptions, EmbeddingOptions {
287287
* Part of Chat completion <a href="https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1">advanced parameters</a>.
288288
*/
289289
@JsonProperty("format")
290-
private String format;
290+
private Object format;
291291

292292
/**
293293
* Sets the length of time for Ollama to keep the model loaded. Valid values for this
@@ -411,7 +411,7 @@ public OllamaOptions withModel(OllamaModel model) {
411411
return this;
412412
}
413413

414-
public OllamaOptions withFormat(String format) {
414+
public OllamaOptions withFormat(Object format) {
415415
this.format = format;
416416
return this;
417417
}
@@ -614,11 +614,11 @@ public void setModel(String model) {
614614
this.model = model;
615615
}
616616

617-
public String getFormat() {
617+
public Object getFormat() {
618618
return this.format;
619619
}
620620

621-
public void setFormat(String format) {
621+
public void setFormat(Object format) {
622622
this.format = format;
623623
}
624624

models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Map;
2121
import java.util.stream.Collectors;
2222

23+
import com.fasterxml.jackson.annotation.JsonProperty;
2324
import org.junit.jupiter.api.Test;
2425

2526
import org.springframework.ai.chat.client.ChatClient;
@@ -228,6 +229,31 @@ void beanStreamOutputConverterRecords() {
228229
assertThat(actorsFilms.movies()).hasSize(5);
229230
}
230231

232+
// Example inspired by https://ollama.com/blog/structured-outputs
233+
@Test
234+
void jsonSchemaFormatStructuredOutput() {
235+
var outputConverter = new BeanOutputConverter<>(CountryInfo.class);
236+
var userPromptTemplate = new PromptTemplate("""
237+
Tell me about {country}.
238+
""");
239+
Map<String, Object> model = Map.of("country", "denmark");
240+
var prompt = userPromptTemplate.create(model,
241+
OllamaOptions.builder()
242+
.withModel(OllamaModel.LLAMA3_2.getName())
243+
.withFormat(outputConverter.getJsonSchemaMap())
244+
.build());
245+
246+
var chatResponse = this.chatModel.call(prompt);
247+
248+
var countryInfo = outputConverter.convert(chatResponse.getResult().getOutput().getText());
249+
assertThat(countryInfo).isNotNull();
250+
assertThat(countryInfo.capital()).isEqualToIgnoringCase("Copenhagen");
251+
}
252+
253+
record CountryInfo(@JsonProperty(required = true) String name, @JsonProperty(required = true) String capital,
254+
@JsonProperty(required = true) List<String> languages) {
255+
}
256+
231257
record ActorsFilmsRecord(String actor, List<String> movies) {
232258

233259
}

spring-ai-core/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.ai.converter;
1818

1919
import java.lang.reflect.Type;
20+
import java.util.Map;
2021
import java.util.Objects;
2122

2223
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -54,6 +55,7 @@
5455
* @author Josh Long
5556
* @author Sebastien Deleuze
5657
* @author Soby Chacko
58+
* @author Thomas Vitale
5759
*/
5860
public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
5961

@@ -220,4 +222,14 @@ public String getJsonSchema() {
220222
return this.jsonSchema;
221223
}
222224

225+
public Map<String, Object> getJsonSchemaMap() {
226+
try {
227+
return this.objectMapper.readValue(this.jsonSchema, Map.class);
228+
}
229+
catch (JsonProcessingException ex) {
230+
logger.error("Could not parse the JSON Schema to a Map object", ex);
231+
throw new IllegalStateException(ex);
232+
}
233+
}
234+
223235
}

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/ollama-chat.adoc

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,90 @@ photo was taken in an area with metallic decorations or fixtures. The overall se
269269
where fruits are being displayed, possibly for convenience or aesthetic purposes.
270270
----
271271

272+
== Structured Outputs
273+
274+
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`.
275+
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.
276+
277+
=== Configuration
278+
279+
Spring AI allows you to configure your response format programmatically using the `OllamaOptions` builder.
280+
281+
==== Using the Chat Options Builder
282+
283+
You can set the response format programmatically with the `OllamaOptions` builder as shown below:
284+
285+
[source,java]
286+
----
287+
String jsonSchema = """
288+
{
289+
"type": "object",
290+
"properties": {
291+
"steps": {
292+
"type": "array",
293+
"items": {
294+
"type": "object",
295+
"properties": {
296+
"explanation": { "type": "string" },
297+
"output": { "type": "string" }
298+
},
299+
"required": ["explanation", "output"],
300+
"additionalProperties": false
301+
}
302+
},
303+
"final_answer": { "type": "string" }
304+
},
305+
"required": ["steps", "final_answer"],
306+
"additionalProperties": false
307+
}
308+
""";
309+
310+
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
311+
OllamaOptions.builder()
312+
.withModel(OllamaModel.LLAMA3_2.getName())
313+
.withFormat(new ObjectMapper().readValue(jsonSchema, Map.class))
314+
.build());
315+
316+
ChatResponse response = this.ollamaChatModel.call(this.prompt);
317+
----
318+
319+
==== Integrating with BeanOutputConverter Utilities
320+
321+
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:
322+
323+
[source,java]
324+
----
325+
record MathReasoning(
326+
@JsonProperty(required = true, value = "steps") Steps steps,
327+
@JsonProperty(required = true, value = "final_answer") String finalAnswer) {
328+
329+
record Steps(
330+
@JsonProperty(required = true, value = "items") Items[] items) {
331+
332+
record Items(
333+
@JsonProperty(required = true, value = "explanation") String explanation,
334+
@JsonProperty(required = true, value = "output") String output) {
335+
}
336+
}
337+
}
338+
339+
var outputConverter = new BeanOutputConverter<>(MathReasoning.class);
340+
341+
Prompt prompt = new Prompt("how can I solve 8x + 7 = -23",
342+
OllamaOptions.builder()
343+
.withModel(OllamaModel.LLAMA3_2.getName())
344+
.withFormat(outputConverter.getJsonSchemaMap())
345+
.build());
346+
347+
ChatResponse response = this.ollamaChatModel.call(this.prompt);
348+
String content = this.response.getResult().getOutput().getText();
349+
350+
MathReasoning mathReasoning = this.outputConverter.convert(this.content);
351+
----
352+
353+
NOTE: Ensure you use the `@JsonProperty(required = true,...)` annotation for generating a schema that accurately marks fields as `required`.
354+
Although this is optional for JSON Schema, it's recommended for the structured response to function correctly.
355+
272356
== OpenAI API Compatibility
273357

274358
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.

0 commit comments

Comments
 (0)