Skip to content

Commit 282ec24

Browse files
authored
feat: structured output for responses API (text) (#688)
1 parent f08a6eb commit 282ec24

13 files changed

+150
-10
lines changed

examples/structured_outputs_chat_completions.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class CalendarEvent < OpenAI::BaseModel
4949

5050
chat_completion
5151
.choices
52-
.filter { !_1.message.refusal }
52+
.reject { _1.message.refusal }
5353
.each do |choice|
5454
pp(choice.message.parsed)
5555
end

examples/structured_outputs_chat_completions_function_calling.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class GetWeather < OpenAI::BaseModel
2424

2525
chat_completion
2626
.choices
27-
.filter { !_1.message.refusal }
27+
.reject { _1.message.refusal }
2828
.flat_map { _1.message.tool_calls.to_a }
2929
.each do |tool_call|
3030
pp(tool_call.function.parsed)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require_relative "../lib/openai"
5+
6+
class Location < OpenAI::BaseModel
7+
required :address, String
8+
required :city, String, doc: "City name"
9+
required :postal_code, String, nil?: true
10+
end
11+
12+
# Participant model with an optional last_name and an enum for status
13+
class Participant < OpenAI::BaseModel
14+
required :first_name, String
15+
required :last_name, String, nil?: true
16+
required :status, OpenAI::EnumOf[:confirmed, :unconfirmed, :tentative]
17+
end
18+
19+
# CalendarEvent model with a list of participants.
20+
class CalendarEvent < OpenAI::BaseModel
21+
required :name, String
22+
required :date, String
23+
required :participants, OpenAI::ArrayOf[Participant]
24+
required :is_virtual, OpenAI::Boolean
25+
required :location,
26+
OpenAI::UnionOf[String, Location],
27+
nil?: true,
28+
doc: "Event location"
29+
end
30+
31+
client = OpenAI::Client.new
32+
33+
response = client.responses.create(
34+
model: "gpt-4o-2024-08-06",
35+
input: [
36+
{role: :system, content: "Extract the event information."},
37+
{
38+
role: :user,
39+
content: <<~CONTENT
40+
Alice Shah and Lena are going to a science fair on Friday at 123 Main St. in San Diego.
41+
They have also invited Jasper Vellani and Talia Groves - Jasper has not responded and Talia said she is thinking about it.
42+
CONTENT
43+
}
44+
],
45+
text: CalendarEvent
46+
)
47+
48+
response
49+
.output
50+
.flat_map { _1.content }
51+
# filter out refusal responses
52+
.grep_v(OpenAI::Models::Responses::ResponseOutputRefusal)
53+
.each do |content|
54+
pp(content.parsed)
55+
end

lib/openai/models/chat/completion_create_params.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ module ResponseFormat
530530
# Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs).
531531
variant -> { OpenAI::ResponseFormatJSONSchema }
532532

533-
# An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::ResponseFormatJSONSchema}.
533+
# An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::Models::ResponseFormatJSONSchema}.
534534
# See examples for more details.
535535
#
536536
# Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs).

lib/openai/models/response_format_json_schema.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class JSONSchema < OpenAI::Internal::Type::BaseModel
5050
# @return [Hash{Symbol=>Object}, nil]
5151
optional :schema,
5252
union: -> {
53-
OpenAI::StructuredOutput::UnionOf[
53+
OpenAI::UnionOf[
5454
OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown],
5555
OpenAI::StructuredOutput::JsonSchemaConverter
5656
]

lib/openai/models/responses/response_create_params.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,13 @@ class ResponseCreateParams < OpenAI::Internal::Type::BaseModel
159159
# - [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs)
160160
#
161161
# @return [OpenAI::Models::Responses::ResponseTextConfig, nil]
162-
optional :text, -> { OpenAI::Responses::ResponseTextConfig }
162+
optional :text,
163+
union: -> {
164+
OpenAI::UnionOf[
165+
OpenAI::Responses::ResponseTextConfig,
166+
OpenAI::StructuredOutput::JsonSchemaConverter
167+
]
168+
}
163169

164170
# @!attribute tool_choice
165171
# How the model should select which tool (or tools) to use when generating a

lib/openai/models/responses/response_format_text_config.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ module ResponseFormatTextConfig
2828
# Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs).
2929
variant :json_schema, -> { OpenAI::Responses::ResponseFormatTextJSONSchemaConfig }
3030

31+
# An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::Models::Responses::ResponseFormatTextJSONSchemaConfig}.
32+
# See examples for more details.
33+
#
34+
# Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs).
35+
variant -> { OpenAI::StructuredOutput::JsonSchemaConverter }
36+
3137
# JSON object response format. An older method of generating JSON responses.
3238
# Using `json_schema` is recommended for models that support it. Note that the
3339
# model will not generate JSON without a system or user message instructing it

lib/openai/models/responses/response_format_text_json_schema_config.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ class ResponseFormatTextJSONSchemaConfig < OpenAI::Internal::Type::BaseModel
1515
# The schema for the response format, described as a JSON Schema object. Learn how
1616
# to build JSON schemas [here](https://json-schema.org/).
1717
#
18-
# @return [Hash{Symbol=>Object}]
19-
required :schema, OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown]
18+
# @return [Hash{Symbol=>Object}, OpenAI::StructuredOutput::JsonSchemaConverter]
19+
required :schema,
20+
union: -> {
21+
OpenAI::UnionOf[
22+
OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown], OpenAI::StructuredOutput::JsonSchemaConverter
23+
]
24+
}
2025

2126
# @!attribute type
2227
# The type of response format being defined. Always `json_schema`.
@@ -52,7 +57,7 @@ class ResponseFormatTextJSONSchemaConfig < OpenAI::Internal::Type::BaseModel
5257
#
5358
# @param name [String] The name of the response format. Must be a-z, A-Z, 0-9, or contain
5459
#
55-
# @param schema [Hash{Symbol=>Object}] The schema for the response format, described as a JSON Schema object.
60+
# @param schema [Hash{Symbol=>Object}, OpenAI::StructuredOutput::JsonSchemaConverter] The schema for the response format, described as a JSON Schema object.
5661
#
5762
# @param description [String] A description of what the response format is for, used by the model to
5863
#

lib/openai/models/responses/response_output_text.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ class ResponseOutputText < OpenAI::Internal::Type::BaseModel
1919
# @return [String]
2020
required :text, String
2121

22+
# @!attribute parsed
23+
# The parsed contents of the output, if JSON schema is specified.
24+
#
25+
# @return [Object, nil]
26+
optional :parsed, OpenAI::Internal::Type::Unknown
27+
2228
# @!attribute type
2329
# The type of the output text. Always `output_text`.
2430
#

lib/openai/resources/responses.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,56 @@ def create(params)
7474
message = "Please use `#stream_raw` for the streaming use case."
7575
raise ArgumentError.new(message)
7676
end
77+
78+
model = nil
79+
case parsed
80+
in {text: OpenAI::StructuredOutput::JsonSchemaConverter => model}
81+
parsed.update(
82+
text: {
83+
format: {
84+
type: :json_schema,
85+
strict: true,
86+
name: model.name.split("::").last,
87+
schema: model.to_json_schema
88+
}
89+
}
90+
)
91+
in {text: {format: OpenAI::StructuredOutput::JsonSchemaConverter => model}}
92+
parsed.fetch(:text).update(
93+
format: {
94+
type: :json_schema,
95+
strict: true,
96+
name: model.name.split("::").last,
97+
schema: model.to_json_schema
98+
}
99+
)
100+
in {text: {format: {type: :json_schema, schema: OpenAI::StructuredOutput::JsonSchemaConverter => model}}}
101+
parsed.dig(:text, :format).store(:schema, model.to_json_schema)
102+
else
103+
end
104+
105+
unwrap = ->(raw) do
106+
if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter)
107+
raw[:output]
108+
&.flat_map do |output|
109+
next [] unless output[:type] == "message"
110+
output[:content].to_a
111+
end
112+
&.each do |content|
113+
next unless content[:type] == "output_text"
114+
parsed = JSON.parse(content.fetch(:text), symbolize_names: true)
115+
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
116+
content.store(:parsed, coerced)
117+
end
118+
end
119+
120+
raw
121+
end
77122
@client.request(
78123
method: :post,
79124
path: "responses",
80125
body: parsed,
126+
unwrap: unwrap,
81127
model: OpenAI::Responses::Response,
82128
options: options
83129
)

0 commit comments

Comments
 (0)