Skip to content
This repository was archived by the owner on Jul 18, 2025. It is now read-only.

Commit ed6c392

Browse files
Add support for structured outputs
1 parent e7f7bd5 commit ed6c392

15 files changed

+266
-49
lines changed

src/main/java/io/github/stefanbratanov/jvm/openai/AssistantsResponseFormat.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ public sealed interface AssistantsResponseFormat
99
*/
1010
record StringResponseFormat(String format) implements AssistantsResponseFormat {}
1111

12-
static AssistantsResponseFormat none() {
13-
return new StringResponseFormat("none");
14-
}
15-
1612
static AssistantsResponseFormat auto() {
1713
return new StringResponseFormat("auto");
1814
}

src/main/java/io/github/stefanbratanov/jvm/openai/AssistantsResponseFormatDeserializer.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
77
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
88
import java.io.IOException;
9+
import java.util.Optional;
910

1011
class AssistantsResponseFormatDeserializer extends StdDeserializer<AssistantsResponseFormat> {
1112

@@ -21,7 +22,12 @@ public AssistantsResponseFormat deserialize(JsonParser p, DeserializationContext
2122
return new AssistantsResponseFormat.StringResponseFormat(node.asText());
2223
} else if (node.isObject()) {
2324
String type = node.get("type").asText();
24-
return new ResponseFormat(type);
25+
if (node.has("json_schema")) {
26+
JsonSchema jsonSchema = p.getCodec().treeToValue(node.get("json_schema"), JsonSchema.class);
27+
return new ResponseFormat(type, Optional.of(jsonSchema));
28+
} else {
29+
return new ResponseFormat(type, Optional.empty());
30+
}
2531
}
2632
throw InvalidFormatException.from(
2733
p, "Expected String or Object", node, AssistantsResponseFormat.class);

src/main/java/io/github/stefanbratanov/jvm/openai/AssistantsResponseFormatSerializer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.fasterxml.jackson.databind.SerializerProvider;
55
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
66
import java.io.IOException;
7+
import java.util.Optional;
78

89
class AssistantsResponseFormatSerializer extends StdSerializer<AssistantsResponseFormat> {
910

@@ -20,6 +21,10 @@ public void serialize(
2021
} else if (value instanceof ResponseFormat responseFormat) {
2122
gen.writeStartObject();
2223
gen.writeStringField("type", responseFormat.type());
24+
Optional<JsonSchema> jsonSchema = responseFormat.jsonSchema();
25+
if (jsonSchema.isPresent()) {
26+
gen.writeObjectField("json_schema", jsonSchema.get());
27+
}
2328
gen.writeEndObject();
2429
}
2530
}

src/main/java/io/github/stefanbratanov/jvm/openai/ChatCompletion.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ public record ChatCompletion(
1515
public record Choice(int index, Message message, Logprobs logprobs, String finishReason) {
1616

1717
/** A chat completion message generated by the model. */
18-
public record Message(String content, List<ToolCall> toolCalls, String role) {}
18+
public record Message(String content, String refusal, List<ToolCall> toolCalls, String role) {}
1919
}
2020
}

src/main/java/io/github/stefanbratanov/jvm/openai/ChatCompletionChunk.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ public record ChatCompletionChunk(
1818
public record Choice(Delta delta, int index, Logprobs logprobs, String finishReason) {
1919

2020
/** A chat completion delta generated by streamed model responses. */
21-
public record Delta(String role, String content, List<ToolCall> toolCalls) {}
21+
public record Delta(String role, String content, String refusal, List<ToolCall> toolCalls) {}
2222
}
2323
}

src/main/java/io/github/stefanbratanov/jvm/openai/ChatMessage.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ record UserMessageWithContentParts(List<ContentPart> content, Optional<String> n
3939
implements UserMessage<List<ContentPart>> {}
4040
}
4141

42-
record AssistantMessage(String content, Optional<String> name, Optional<List<ToolCall>> toolCalls)
42+
record AssistantMessage(
43+
String content,
44+
Optional<String> refusal,
45+
Optional<String> name,
46+
Optional<List<ToolCall>> toolCalls)
4347
implements ChatMessage {
4448
@Override
4549
public String role() {
@@ -67,11 +71,18 @@ static UserMessageWithContentParts userMessage(ContentPart... content) {
6771
}
6872

6973
static AssistantMessage assistantMessage(String content) {
70-
return new AssistantMessage(content, Optional.empty(), Optional.empty());
74+
return new AssistantMessage(content, Optional.empty(), Optional.empty(), Optional.empty());
7175
}
7276

7377
static AssistantMessage assistantMessage(String content, List<ToolCall> toolCalls) {
74-
return new AssistantMessage(content, Optional.empty(), Optional.of(toolCalls));
78+
return new AssistantMessage(
79+
content, Optional.empty(), Optional.empty(), Optional.of(toolCalls));
80+
}
81+
82+
static AssistantMessage assistantMessage(
83+
String content, String refusal, List<ToolCall> toolCalls) {
84+
return new AssistantMessage(
85+
content, Optional.of(refusal), Optional.empty(), Optional.of(toolCalls));
7586
}
7687

7788
static ToolMessage toolMessage(String content, String toolCallId) {
Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,17 @@
11
package io.github.stefanbratanov.jvm.openai;
22

3-
import com.fasterxml.jackson.databind.JsonNode;
4-
import java.io.IOException;
5-
import java.util.AbstractMap;
63
import java.util.Map;
74
import java.util.Optional;
8-
import java.util.stream.Collectors;
95

106
/** Function that the model may generate JSON inputs for. */
117
public record Function(
12-
String name, Optional<String> description, Optional<Map<String, Object>> parameters) {
8+
String name,
9+
Optional<String> description,
10+
Optional<Map<String, Object>> parameters,
11+
Optional<Boolean> strict) {
1312

1413
public Function {
15-
parameters = parameters.map(this::parametersWithoutJsonEscaping);
16-
}
17-
18-
private Map<String, Object> parametersWithoutJsonEscaping(Map<String, Object> parameters) {
19-
return parameters.entrySet().stream()
20-
.map(
21-
entry -> {
22-
if (entry.getValue() instanceof String value) {
23-
try {
24-
JsonNode node = ObjectMapperSingleton.getInstance().readTree(value);
25-
if (node != null && !node.isNull()) {
26-
return new AbstractMap.SimpleEntry<>(entry.getKey(), node);
27-
}
28-
} catch (IOException ex) {
29-
return entry;
30-
}
31-
}
32-
return entry;
33-
})
34-
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
14+
parameters = parameters.map(Utils::mapWithoutJsonEscaping);
3515
}
3616

3717
public static Builder newBuilder() {
@@ -43,6 +23,7 @@ public static class Builder {
4323
private String name;
4424
private Optional<String> description = Optional.empty();
4525
private Optional<Map<String, Object>> parameters = Optional.empty();
26+
private Optional<Boolean> strict = Optional.empty();
4627

4728
/**
4829
* @param name The name of the function to be called. Must be a-z, A-Z, 0-9, or contain
@@ -72,8 +53,18 @@ public Builder parameters(Map<String, Object> parameters) {
7253
return this;
7354
}
7455

56+
/**
57+
* @param strict Whether to enable strict schema adherence when generating the function call. If
58+
* set to true, the model will follow the exact schema defined in the parameters field. Only
59+
* a subset of JSON Schema is supported when strict is true.
60+
*/
61+
public Builder strict(boolean strict) {
62+
this.strict = Optional.of(strict);
63+
return this;
64+
}
65+
7566
public Function build() {
76-
return new Function(name, description, parameters);
67+
return new Function(name, description, parameters, strict);
7768
}
7869
}
7970
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.github.stefanbratanov.jvm.openai;
2+
3+
import java.util.Map;
4+
import java.util.Optional;
5+
6+
public record JsonSchema(
7+
String name,
8+
Optional<String> description,
9+
Optional<Map<String, Object>> schema,
10+
Optional<Boolean> strict) {
11+
12+
public JsonSchema {
13+
schema = schema.map(Utils::mapWithoutJsonEscaping);
14+
}
15+
16+
public static Builder newBuilder() {
17+
return new Builder();
18+
}
19+
20+
public static class Builder {
21+
22+
private String name;
23+
private Optional<String> description = Optional.empty();
24+
private Optional<Map<String, Object>> schema = Optional.empty();
25+
private Optional<Boolean> strict = Optional.empty();
26+
27+
/**
28+
* @param name The name of the response format.
29+
*/
30+
public Builder name(String name) {
31+
this.name = name;
32+
return this;
33+
}
34+
35+
/**
36+
* @param description A description of what the response format is for, used by the model to
37+
* determine how to respond in the format.
38+
*/
39+
public Builder description(String description) {
40+
this.description = Optional.of(description);
41+
return this;
42+
}
43+
44+
/**
45+
* @param schema The schema for the response format, described as a JSON Schema object. The JSON
46+
* schema should be defined as {@link Map} where a value could be a raw escaped JSON {@link
47+
* String} and it will be serialized without escaping.
48+
*/
49+
public Builder schema(Map<String, Object> schema) {
50+
this.schema = Optional.of(schema);
51+
return this;
52+
}
53+
54+
/**
55+
* @param strict Whether to enable strict schema adherence when generating the output. If set to
56+
* true, the model will always follow the exact schema defined in the schema field. Only a
57+
* subset of JSON Schema is supported when strict is true.
58+
*/
59+
public Builder strict(boolean strict) {
60+
this.strict = Optional.of(strict);
61+
return this;
62+
}
63+
64+
public JsonSchema build() {
65+
return new JsonSchema(name, description, schema, strict);
66+
}
67+
}
68+
}

src/main/java/io/github/stefanbratanov/jvm/openai/Logprobs.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import java.util.List;
44

55
/** Log probability information */
6-
public record Logprobs(List<Content> content) {
6+
public record Logprobs(List<Content> content, List<Refusal> refusal) {
77

88
public record Content(
99
String token, double logprob, List<Byte> bytes, List<TopLogprob> topLogprobs) {}
1010

11+
public record Refusal(
12+
String token, double logprob, List<Byte> bytes, List<TopLogprob> topLogprobs) {}
13+
1114
public record TopLogprob(String token, double logprob, List<Byte> bytes) {}
1215
}
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
package io.github.stefanbratanov.jvm.openai;
22

3+
import java.util.Optional;
4+
35
/** An object specifying the format that the model must output. */
4-
public record ResponseFormat(String type) implements AssistantsResponseFormat {
6+
public record ResponseFormat(String type, Optional<JsonSchema> jsonSchema)
7+
implements AssistantsResponseFormat {
58
public static ResponseFormat text() {
6-
return new ResponseFormat("text");
9+
return new ResponseFormat("text", Optional.empty());
710
}
811

912
public static ResponseFormat json() {
10-
return new ResponseFormat("json_object");
13+
return new ResponseFormat("json_object", Optional.empty());
14+
}
15+
16+
public static ResponseFormat jsonSchema(JsonSchema jsonSchema) {
17+
return new ResponseFormat("json_schema", Optional.of(jsonSchema));
1118
}
1219
}

0 commit comments

Comments
 (0)