Skip to content

Commit a613a39

Browse files
ms-jpqstainless-app[bot]charleseff
authored
feat: initial structured outputs support (#565)
Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Charles Finkel <[email protected]>
1 parent 7720dca commit a613a39

16 files changed

+860
-1
lines changed
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+
# typed: strong
4+
5+
require_relative "../lib/openai"
6+
7+
class Location < OpenAI::BaseModel
8+
required :address, String
9+
required :city, String, doc: "City name"
10+
required :postal_code, String, nil?: true
11+
end
12+
13+
# Participant model with an optional last_name and an enum for status
14+
class Participant < OpenAI::BaseModel
15+
required :first_name, String
16+
required :last_name, String, nil?: true
17+
required :status, OpenAI::EnumOf[:confirmed, :unconfirmed, :tentative]
18+
end
19+
20+
# CalendarEvent model with a list of participants.
21+
class CalendarEvent < OpenAI::BaseModel
22+
required :name, String
23+
required :date, String
24+
required :participants, OpenAI::ArrayOf[Participant]
25+
required :is_virtual, OpenAI::Boolean
26+
required :location,
27+
OpenAI::UnionOf[String, Location],
28+
nil?: true,
29+
doc: "Event location"
30+
end
31+
32+
# # gets API Key from environment variable `OPENAI_API_KEY`
33+
client = OpenAI::Client.new
34+
35+
chat_completion = client.chat.completions.create(
36+
model: "gpt-4o-2024-08-06",
37+
messages: [
38+
{role: :system, content: "Extract the event information."},
39+
{
40+
role: :user,
41+
content: <<~CONTENT
42+
Alice Shah and Lena are going to a science fair on Friday at 123 Main St. in San Diego.
43+
They have also invited Jasper Vellani and Talia Groves - Jasper has not responded and Talia said she is thinking about it.
44+
CONTENT
45+
}
46+
],
47+
response_format: CalendarEvent
48+
)
49+
50+
chat_completion
51+
.choices
52+
.filter { !_1.message.refusal }
53+
.each do |choice|
54+
pp(choice.message.parsed)
55+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
# typed: strong
4+
5+
require_relative "../lib/openai"
6+
7+
class GetWeather < OpenAI::BaseModel
8+
required :location, String, doc: "City and country e.g. Bogotá, Colombia"
9+
end
10+
11+
# gets API Key from environment variable `OPENAI_API_KEY`
12+
client = OpenAI::Client.new
13+
14+
chat_completion = client.chat.completions.create(
15+
model: "gpt-4o-2024-08-06",
16+
messages: [
17+
{
18+
role: :user,
19+
content: "What's the weather like in Paris today?"
20+
}
21+
],
22+
tools: [GetWeather]
23+
)
24+
25+
chat_completion
26+
.choices
27+
.filter { !_1.message.refusal }
28+
.flat_map { _1.message.tool_calls.to_a }
29+
.each do |tool_call|
30+
pp(tool_call.function.parsed)
31+
end

lib/openai.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
require_relative "openai/internal/stream"
5151
require_relative "openai/internal/cursor_page"
5252
require_relative "openai/internal/page"
53+
require_relative "openai/helpers/structured_output/json_schema_converter"
54+
require_relative "openai/helpers/structured_output/boolean"
55+
require_relative "openai/helpers/structured_output/enum_of"
56+
require_relative "openai/helpers/structured_output/union_of"
57+
require_relative "openai/helpers/structured_output/array_of"
58+
require_relative "openai/helpers/structured_output/base_model"
59+
require_relative "openai/helpers/structured_output"
60+
require_relative "openai/structured_output"
5361
require_relative "openai/models/reasoning_effort"
5462
require_relative "openai/models/chat/chat_completion_message"
5563
require_relative "openai/models/fine_tuning/fine_tuning_job_wandb_integration_object"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
# Helpers for the structured output API.
6+
#
7+
# see https://platform.openai.com/docs/guides/structured-outputs
8+
# see https://json-schema.org
9+
#
10+
# Based on the DSL in {OpenAI::Internal::Type}, but currently only support the limited subset of JSON schema types used in structured output APIs.
11+
#
12+
# Supported types: {NilClass} {String} {Symbol} {Integer} {Float} {OpenAI::Boolean}, {OpenAI::EnumOf}, {OpenAI::UnionOf}, {OpenAI::ArrayOf}, {OpenAI::BaseModel}
13+
module StructuredOutput
14+
end
15+
end
16+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
module StructuredOutput
6+
# @generic Elem
7+
#
8+
# @example
9+
# example = OpenAI::ArrayOf[Integer]
10+
#
11+
# @example
12+
# example = OpenAI::ArrayOf[Integer, nil?: true, doc: "hi there!"]
13+
class ArrayOf < OpenAI::Internal::Type::ArrayOf
14+
include OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
15+
16+
# @api private
17+
#
18+
# @param state [Hash{Symbol=>Object}]
19+
#
20+
# @option state [Hash{Object=>String}] :defs
21+
#
22+
# @option state [Array<String>] :path
23+
#
24+
# @return [Hash{Symbol=>Object}]
25+
def to_json_schema_inner(state:)
26+
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.cache_def!(state, type: self) do
27+
state.fetch(:path) << "[]"
28+
items = OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_json_schema_inner(
29+
item_type,
30+
state: state
31+
)
32+
items = OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_nilable(items) if nilable?
33+
34+
schema = {type: "array", items: items}
35+
description.nil? ? schema : schema.update(description: description)
36+
end
37+
end
38+
39+
# @return [String, nil]
40+
attr_reader :description
41+
42+
def initialize(type_info, spec = {})
43+
super
44+
@description = [type_info, spec].grep(Hash).filter_map { _1[:doc] }.first
45+
end
46+
end
47+
end
48+
end
49+
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
module StructuredOutput
6+
# Represents a response from OpenAI's API where the model's output has been structured according to a schema predefined by the user.
7+
#
8+
# This class is specifically used when making requests with the `response_format` parameter set to use structured output (e.g., JSON).
9+
#
10+
# See {examples/structured_outputs_chat_completions.rb} for a complete example of use
11+
class BaseModel < OpenAI::Internal::Type::BaseModel
12+
extend OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
13+
14+
class << self
15+
# @return [Hash{Symbol=>Object}]
16+
def to_json_schema = OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_json_schema(self)
17+
18+
# @api private
19+
#
20+
# @param state [Hash{Symbol=>Object}]
21+
#
22+
# @option state [Hash{Object=>String}] :defs
23+
#
24+
# @option state [Array<String>] :path
25+
#
26+
# @return [Hash{Symbol=>Object}]
27+
def to_json_schema_inner(state:)
28+
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.cache_def!(state, type: self) do
29+
path = state.fetch(:path)
30+
properties = fields.to_h do |name, field|
31+
type, nilable = field.fetch_values(:type, :nilable)
32+
new_state = {**state, path: [*path, ".#{name}"]}
33+
34+
schema =
35+
case type
36+
in {"$ref": String}
37+
type
38+
in OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
39+
type.to_json_schema_inner(state: new_state).update(field.slice(:description))
40+
else
41+
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_json_schema_inner(
42+
type,
43+
state: new_state
44+
)
45+
end
46+
schema = OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.to_nilable(schema) if nilable
47+
[name, schema]
48+
end
49+
50+
{
51+
type: "object",
52+
properties: properties,
53+
required: properties.keys.map(&:to_s),
54+
additionalProperties: false
55+
}
56+
end
57+
end
58+
end
59+
60+
class << self
61+
def required(name_sym, type_info, spec = {})
62+
super
63+
64+
doc = [type_info, spec].grep(Hash).filter_map { _1[:doc] }.first
65+
known_fields.fetch(name_sym).update(description: doc) unless doc.nil?
66+
end
67+
68+
def optional(...)
69+
# rubocop:disable Layout/LineLength
70+
message = "`optional` is not supported for structured output APIs, use `#required` with `nil?: true` instead"
71+
# rubocop:enable Layout/LineLength
72+
raise RuntimeError.new(message)
73+
end
74+
end
75+
end
76+
end
77+
end
78+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
module StructuredOutput
6+
# @abstract
7+
#
8+
# Ruby does not have a "boolean" Class, this is something for models to refer to.
9+
class Boolean < OpenAI::Internal::Type::Boolean
10+
extend OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
11+
# rubocop:disable Lint/UnusedMethodArgument
12+
13+
# @api private
14+
#
15+
# @param state [Hash{Symbol=>Object}]
16+
#
17+
# @option state [Hash{Object=>String}] :defs
18+
#
19+
# @option state [Array<String>] :path
20+
#
21+
# @return [Hash{Symbol=>Object}]
22+
def self.to_json_schema_inner(state:) = {type: "boolean"}
23+
24+
# rubocop:enable Lint/UnusedMethodArgument
25+
end
26+
end
27+
end
28+
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
module StructuredOutput
6+
# @generic Value
7+
#
8+
# @example
9+
# example = OpenAI::EnumOf[:foo, :bar, :zoo]
10+
#
11+
# @example
12+
# example = OpenAI::EnumOf[1, 2, 3]
13+
class EnumOf
14+
include OpenAI::Internal::Type::Enum
15+
include OpenAI::Helpers::StructuredOutput::JsonSchemaConverter
16+
17+
# @api private
18+
#
19+
# @param state [Hash{Symbol=>Object}]
20+
#
21+
# @option state [Hash{Object=>String}] :defs
22+
#
23+
# @option state [Array<String>] :path
24+
#
25+
# @return [Hash{Symbol=>Object}]
26+
def to_json_schema_inner(state:)
27+
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter.cache_def!(state, type: self) do
28+
types = values.map do
29+
case _1
30+
in NilClass
31+
"null"
32+
in true | false
33+
"boolean"
34+
in Integer
35+
"integer"
36+
in Float
37+
"number"
38+
in Symbol
39+
"string"
40+
end
41+
end
42+
.uniq
43+
44+
{
45+
type: types.length == 1 ? types.first : types,
46+
enum: values.map { _1.is_a?(Symbol) ? _1.to_s : _1 }
47+
}
48+
end
49+
end
50+
51+
private_class_method :new
52+
53+
def self.[](...) = new(...)
54+
55+
# @return [Array<generic<Value>>]
56+
attr_reader :values
57+
58+
# @param values [Array<generic<Value>>]
59+
def initialize(*values) = (@values = values.map { _1.is_a?(String) ? _1.to_sym : _1 })
60+
end
61+
end
62+
end
63+
end

0 commit comments

Comments
 (0)