Skip to content

Commit 93aa7af

Browse files
ms-jpqstainless-app[bot]
authored andcommitted
fix: ensure openapi refs are correctly linked (#746)
1 parent 3992a21 commit 93aa7af

File tree

5 files changed

+188
-71
lines changed

5 files changed

+188
-71
lines changed

examples/structured_outputs_chat_completions.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class CalendarEvent < OpenAI::BaseModel
2222
required :name, String
2323
required :date, String
2424
required :participants, OpenAI::ArrayOf[Participant]
25+
required :optional_participants, OpenAI::ArrayOf[Participant], nil?: true
2526
required :is_virtual, OpenAI::Boolean
2627
required :location,
2728
OpenAI::UnionOf[String, Location],

examples/structured_outputs_responses.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class CalendarEvent < OpenAI::BaseModel
2121
required :name, String
2222
required :date, String
2323
required :participants, OpenAI::ArrayOf[Participant]
24+
required :optional_participants, OpenAI::ArrayOf[Participant], nil?: true
2425
required :is_virtual, OpenAI::Boolean
2526
required :location,
2627
OpenAI::UnionOf[String, Location],

lib/openai/helpers/structured_output/json_schema_converter.rb

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,24 @@ module Helpers
55
module StructuredOutput
66
# To customize the JSON schema conversion for a type, implement the `JsonSchemaConverter` interface.
77
module JsonSchemaConverter
8-
POINTER = Object.new.freeze
9-
COUNTER = Object.new.freeze
8+
# @api private
9+
POINTER = Object.new.tap do
10+
_1.define_singleton_method(:inspect) do
11+
"#<#{OpenAI::Helpers::StructuredOutput::JsonSchemaConverter}::POINTER>"
12+
end
13+
end.freeze
14+
# @api private
15+
COUNTER = Object.new.tap do
16+
_1.define_singleton_method(:inspect) do
17+
"#<#{OpenAI::Helpers::StructuredOutput::JsonSchemaConverter}::COUNTER>"
18+
end
19+
end.freeze
20+
# @api private
21+
NO_REF = Object.new.tap do
22+
_1.define_singleton_method(:inspect) do
23+
"#<#{OpenAI::Helpers::StructuredOutput::JsonSchemaConverter}::NO_REF>"
24+
end
25+
end.freeze
1026

1127
# rubocop:disable Lint/UnusedMethodArgument
1228

@@ -34,7 +50,12 @@ def to_nilable(schema)
3450
null = "null"
3551
case schema
3652
in {"$ref": String}
37-
{anyOf: [schema, {type: null}]}
53+
{
54+
anyOf: [
55+
schema.update(OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::NO_REF => true),
56+
{type: null}
57+
]
58+
}
3859
in {anyOf: schemas}
3960
null = {type: null}
4061
schemas.any? { _1 == null || _1 == {type: ["null"]} } ? schema : {anyOf: [*schemas, null]}
@@ -71,7 +92,7 @@ def cache_def!(state, type:, &blk)
7192
}
7293
defs.store(type, stored)
7394
schema = blk.call
74-
ref_path.replace("#/definitions/#{path.join('/')}")
95+
ref_path.replace("#/$defs/#{path.join('/')}")
7596
stored.update(schema)
7697
ref
7798
end
@@ -92,17 +113,20 @@ def to_json_schema(type)
92113
reused_defs = {}
93114
defs.each_value do |acc|
94115
ref = acc.fetch(OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::POINTER)
116+
if (no_ref = ref.delete(OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::NO_REF))
117+
acc[OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::COUNTER] -= 1
118+
end
119+
cnt = acc.fetch(OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::COUNTER)
120+
95121
sch = acc.except(
96122
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::POINTER,
97123
OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::COUNTER
98124
)
99-
if acc.fetch(OpenAI::Helpers::StructuredOutput::JsonSchemaConverter::COUNTER) > 1
100-
reused_defs.store(ref.fetch(:$ref), sch)
101-
else
102-
ref.replace(sch)
103-
end
125+
cnt > 1 && !no_ref ? reused_defs.store(ref.fetch(:$ref), sch) : ref.replace(sch)
104126
end
105-
reused_defs.empty? ? schema : {"$defs": reused_defs}.update(schema)
127+
128+
xformed = reused_defs.transform_keys { _1.delete_prefix("#/$defs/") }
129+
xformed.empty? ? schema : {"$defs": xformed}.update(schema)
106130
end
107131

108132
# @api private

rbi/openai/helpers/structured_output/json_schema_converter.rbi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ module OpenAI
77

88
# To customize the JSON schema conversion for a type, implement the `JsonSchemaConverter` interface.
99
module JsonSchemaConverter
10+
# @api private
1011
POINTER = T.let(Object.new.freeze, T.anything)
12+
# @api private
1113
COUNTER = T.let(Object.new.freeze, T.anything)
14+
# @api private
15+
NO_REF = T.let(Object.new.freeze, T.anything)
1216

1317
Input =
1418
T.type_alias do

test/openai/helpers/structured_output_test.rb

Lines changed: 148 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -137,78 +137,165 @@ class M5 < OpenAI::Helpers::StructuredOutput::BaseModel
137137

138138
class M6 < OpenAI::Helpers::StructuredOutput::BaseModel
139139
required :a, String
140-
required :b, -> { M6 }, nil?: true
140+
required :b, -> { M6 }
141+
end
142+
143+
class M7 < OpenAI::Helpers::StructuredOutput::BaseModel
144+
required :a, -> { M5 }
145+
required :b, M5
146+
end
147+
148+
class M8 < OpenAI::Helpers::StructuredOutput::BaseModel
149+
required :a, -> { M5 }
150+
required :b, M5, nil?: true
151+
end
152+
153+
class M9 < OpenAI::Helpers::StructuredOutput::BaseModel
154+
required :a, -> { M10 }
155+
required :b, -> { M10 }
156+
end
157+
158+
class M10 < OpenAI::Helpers::StructuredOutput::BaseModel
159+
required :b, -> { M9 }
141160
end
142161

143162
def test_definition_reusing
144163
cases = {
145-
M4 => {
146-
:$defs => {
147-
"#/definitions/.a" => {type: "string", enum: ["one"]}
164+
M6 => {
165+
:$defs =>
166+
{
167+
"" =>
168+
{
169+
type: "object",
170+
properties: {a: {type: "string"}, b: {:$ref => "#/$defs/"}},
171+
required: %w[a b],
172+
additionalProperties: false
173+
}
174+
},
175+
:$ref => "#/$defs/"
176+
},
177+
M7 =>
178+
{
179+
:$defs =>
180+
{
181+
".a" =>
182+
{
183+
type: "object",
184+
properties: {
185+
a: {
186+
anyOf: [
187+
{
188+
type: "array",
189+
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
190+
},
191+
{type: "null"}
192+
]
193+
},
194+
b: {
195+
anyOf: [
196+
{
197+
type: "array",
198+
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
199+
},
200+
{type: "null"}
201+
]
202+
}
203+
},
204+
required: %w[a b],
205+
additionalProperties: false
206+
}
207+
},
208+
:type => "object",
209+
:properties => {a: {:$ref => "#/$defs/.a"}, b: {:$ref => "#/$defs/.a"}},
210+
:required => %w[a b],
211+
:additionalProperties => false
148212
},
149-
:type => "object",
150-
:properties => {
151-
a: {"$ref": "#/definitions/.a"},
152-
b: {
153-
anyOf: [
154-
{"$ref": "#/definitions/.a"},
155-
{type: "null"}
156-
]
213+
M8 => {
214+
type: "object",
215+
properties: {
216+
a: {
217+
type: "object",
218+
properties: {
219+
a: {
220+
anyOf: [
221+
{
222+
type: "array",
223+
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
224+
},
225+
{type: "null"}
226+
]
227+
},
228+
b: {
229+
anyOf: [
230+
{
231+
type: "array",
232+
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
233+
},
234+
{type: "null"}
235+
]
236+
}
237+
},
238+
required: %w[a b],
239+
additionalProperties: false
157240
},
158-
c: {
241+
b: {
159242
anyOf: [
160243
{
161-
type: "array",
162-
items: {
163-
anyOf: [
164-
{"$ref": "#/definitions/.a"},
165-
{type: "null"}
166-
]
244+
type: "object",
245+
properties: {
246+
a: {
247+
anyOf: [
248+
{
249+
type: "array",
250+
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
251+
},
252+
{type: "null"}
253+
]
254+
},
255+
b: {
256+
anyOf: [
257+
{
258+
type: "array",
259+
items: {
260+
anyOf: [
261+
{type: "string", enum: ["one"]},
262+
{type: "null"}
263+
]
264+
}
265+
},
266+
{type: "null"}
267+
]
268+
}
167269
},
168-
description: "nested"
270+
required: %w[a b],
271+
additionalProperties: false
169272
},
170273
{type: "null"}
171274
]
172275
}
173276
},
174-
:required => %w[a b c],
175-
:additionalProperties => false
176-
},
177-
M5 => {
178-
:$defs => {
179-
"#/definitions/.a/[]" => {
180-
type: "array",
181-
items: {anyOf: [{type: "string", enum: ["one"]}, {type: "null"}]}
182-
}
183-
},
184-
:type => "object",
185-
:properties => {
186-
a: {anyOf: [{:$ref => "#/definitions/.a/[]"}, {type: "null"}]},
187-
b: {anyOf: [{:$ref => "#/definitions/.a/[]"}, {type: "null"}]}
188-
},
189-
:required => %w[a b],
190-
:additionalProperties => false
191-
},
192-
OpenAI::Helpers::StructuredOutput::UnionOf[E1, A2] => {
193-
:$defs => {"#/definitions/?.0" => {type: "string", enum: ["one"]}},
194-
:anyOf => [
195-
{:$ref => "#/definitions/?.0"},
196-
{type: "array", items: {anyOf: [{:$ref => "#/definitions/?.0"}, {type: "null"}]}}
197-
]
277+
required: %w[a b],
278+
additionalProperties: false
198279
},
199-
M6 => {
200-
:$defs => {
201-
"#/definitions/" => {
202-
type: "object",
203-
properties: {
204-
a: {type: "string"},
205-
b: {anyOf: [{:$ref => "#/definitions/"}, {type: "null"}]}
206-
},
207-
required: %w[a b],
208-
additionalProperties: false
209-
}
210-
},
211-
:$ref => "#/definitions/"
280+
M10 => {
281+
:$defs =>
282+
{
283+
"" =>
284+
{
285+
type: "object",
286+
properties: {
287+
b: {
288+
type: "object",
289+
properties: {a: {:$ref => "#/$defs/"}, b: {:$ref => "#/$defs/"}},
290+
required: %w[a b],
291+
additionalProperties: false
292+
}
293+
},
294+
required: ["b"],
295+
additionalProperties: false
296+
}
297+
},
298+
:$ref => "#/$defs/"
212299
}
213300
}
214301

@@ -220,23 +307,23 @@ def test_definition_reusing
220307
end
221308
end
222309

223-
class M7 < OpenAI::Helpers::StructuredOutput::BaseModel
310+
class M11 < OpenAI::Helpers::StructuredOutput::BaseModel
224311
required :a, OpenAI::Helpers::StructuredOutput::ParsedJson
225312
end
226313

227314
def test_parsed_json
228315
assert_pattern do
229-
M7.new(a: {dog: "woof"}) => {a: {dog: "woof"}}
316+
M11.new(a: {dog: "woof"}) => {a: {dog: "woof"}}
230317
end
231318

232319
err = JSON::ParserError.new("unexpected token at 'invalid json'")
233320

234-
m1 = M7.new(a: err)
321+
m1 = M11.new(a: err)
235322
assert_raises(OpenAI::Errors::ConversionError) do
236323
m1.a
237324
end
238325

239-
m2 = OpenAI::Internal::Type::Converter.coerce(M7, {a: err})
326+
m2 = OpenAI::Internal::Type::Converter.coerce(M11, {a: err})
240327
assert_raises(OpenAI::Errors::ConversionError) do
241328
m2.a
242329
end

0 commit comments

Comments
 (0)