Skip to content

Commit 2e6fc06

Browse files
ms-jpqstainless-app[bot]
authored andcommitted
feat: ensure partial jsons in structured ouput are handled gracefully (#740)
1 parent 60a2061 commit 2e6fc06

File tree

9 files changed

+91
-11
lines changed

9 files changed

+91
-11
lines changed

lib/openai.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
require_relative "openai/helpers/structured_output/union_of"
5757
require_relative "openai/helpers/structured_output/array_of"
5858
require_relative "openai/helpers/structured_output/base_model"
59+
require_relative "openai/helpers/structured_output/parsed_json"
5960
require_relative "openai/helpers/structured_output"
6061
require_relative "openai/structured_output"
6162
require_relative "openai/models/reasoning_effort"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
module OpenAI
4+
module Helpers
5+
module StructuredOutput
6+
# @abstract
7+
#
8+
# Like OpenAI::Internal::Type::Unknown, but for parsed JSON values, which can be incomplete or malformed.
9+
class ParsedJson < OpenAI::Internal::Type::Unknown
10+
class << self
11+
# @api private
12+
#
13+
# No coercion needed for Unknown type.
14+
#
15+
# @param value [Object]
16+
#
17+
# @param state [Hash{Symbol=>Object}] .
18+
#
19+
# @option state [Boolean] :translate_names
20+
#
21+
# @option state [Boolean] :strictness
22+
#
23+
# @option state [Hash{Symbol=>Object}] :exactness
24+
#
25+
# @option state [Class<StandardError>] :error
26+
#
27+
# @option state [Integer] :branched
28+
#
29+
# @return [Object]
30+
def coerce(value, state:)
31+
state.fetch(:exactness)[:yes] += 1
32+
(state[:error] = value) if value.is_a?(StandardError)
33+
34+
value
35+
end
36+
end
37+
end
38+
end
39+
end
40+
end

lib/openai/models/chat/chat_completion_message.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class ChatCompletionMessage < OpenAI::Internal::Type::BaseModel
1414
# The parsed contents of the message, if JSON schema is specified.
1515
#
1616
# @return [Object, nil]
17-
optional :parsed, OpenAI::Internal::Type::Unknown
17+
optional :parsed, OpenAI::StructuredOutput::ParsedJson
1818

1919
# @!attribute refusal
2020
# The refusal message generated by the model.

lib/openai/models/chat/chat_completion_message_tool_call.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class Function < OpenAI::Internal::Type::BaseModel
4444
# The parsed contents of the arguments.
4545
#
4646
# @return [Object, nil]
47-
required :parsed, OpenAI::Internal::Type::Unknown
47+
required :parsed, OpenAI::StructuredOutput::ParsedJson
4848

4949
# @!attribute name
5050
# The name of the function to call.

lib/openai/models/responses/response_function_tool_call.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class ResponseFunctionToolCall < OpenAI::Internal::Type::BaseModel
1414
# The parsed contents of the arguments.
1515
#
1616
# @return [Object, nil]
17-
required :parsed, OpenAI::Internal::Type::Unknown
17+
required :parsed, OpenAI::StructuredOutput::ParsedJson
1818

1919
# @!attribute call_id
2020
# The unique ID of the function tool call generated by the model.

lib/openai/models/responses/response_output_text.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ResponseOutputText < OpenAI::Internal::Type::BaseModel
2323
# The parsed contents of the output, if JSON schema is specified.
2424
#
2525
# @return [Object, nil]
26-
optional :parsed, OpenAI::Internal::Type::Unknown
26+
optional :parsed, OpenAI::StructuredOutput::ParsedJson
2727

2828
# @!attribute type
2929
# The type of the output text. Always `output_text`.

lib/openai/resources/chat/completions.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ def create(params)
104104
raise ArgumentError.new(message)
105105
end
106106

107-
# rubocop:disable Layout/LineLength
108107
model = nil
109108
tool_models = {}
110109
case parsed
@@ -157,11 +156,16 @@ def create(params)
157156
else
158157
end
159158

159+
# rubocop:disable Metrics/BlockLength
160160
unwrap = ->(raw) do
161161
if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter)
162162
raw[:choices]&.each do |choice|
163163
message = choice.fetch(:message)
164-
parsed = JSON.parse(message.fetch(:content), symbolize_names: true)
164+
begin
165+
parsed = JSON.parse(message.fetch(:content), symbolize_names: true)
166+
rescue JSON::ParserError => e
167+
parsed = e
168+
end
165169
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
166170
message.store(:parsed, coerced)
167171
end
@@ -171,15 +175,19 @@ def create(params)
171175
func = tool_call.fetch(:function)
172176
next if (model = tool_models[func.fetch(:name)]).nil?
173177

174-
parsed = JSON.parse(func.fetch(:arguments), symbolize_names: true)
178+
begin
179+
parsed = JSON.parse(func.fetch(:arguments), symbolize_names: true)
180+
rescue JSON::ParserError => e
181+
parsed = e
182+
end
175183
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
176184
func.store(:parsed, coerced)
177185
end
178186
end
179187

180188
raw
181189
end
182-
# rubocop:enable Layout/LineLength
190+
# rubocop:enable Metrics/BlockLength
183191

184192
@client.request(
185193
method: :post,

lib/openai/resources/responses.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,15 +442,23 @@ def parse_structured_outputs!(raw, model, tool_models)
442442
end
443443
&.each do |content|
444444
next unless content[:type] == "output_text"
445-
parsed = JSON.parse(content.fetch(:text), symbolize_names: true)
445+
begin
446+
parsed = JSON.parse(content.fetch(:text), symbolize_names: true)
447+
rescue JSON::ParserError => e
448+
parsed = e
449+
end
446450
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
447451
content.store(:parsed, coerced)
448452
end
449453
end
450454
raw[:output]&.each do |output|
451455
next unless output[:type] == "function_call"
452456
next if (model = tool_models[output.fetch(:name)]).nil?
453-
parsed = JSON.parse(output.fetch(:arguments), symbolize_names: true)
457+
begin
458+
parsed = JSON.parse(output.fetch(:arguments), symbolize_names: true)
459+
rescue JSON::ParserError => e
460+
parsed = e
461+
end
454462
coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed)
455463
output.store(:parsed, coerced)
456464
end

test/openai/helpers/structured_output_test.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def test_coerce
5252
exactness, expect = rhs
5353
state = OpenAI::Internal::Type::Converter.new_coerce_state
5454
assert_pattern do
55-
OpenAI::Internal::Type::Converter.coerce(target, input, state: state) => ^expect
55+
coerced = OpenAI::Internal::Type::Converter.coerce(target, input, state: state)
56+
coerced => ^expect
5657
state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness
5758
end
5859
end
@@ -218,4 +219,26 @@ def test_definition_reusing
218219
end
219220
end
220221
end
222+
223+
class M7 < OpenAI::Helpers::StructuredOutput::BaseModel
224+
required :a, OpenAI::Helpers::StructuredOutput::ParsedJson
225+
end
226+
227+
def test_parsed_json
228+
assert_pattern do
229+
M7.new(a: {dog: "woof"}) => {a: {dog: "woof"}}
230+
end
231+
232+
err = JSON::ParserError.new("unexpected token at 'invalid json'")
233+
234+
m1 = M7.new(a: err)
235+
assert_raises(OpenAI::Errors::ConversionError) do
236+
m1.a
237+
end
238+
239+
m2 = OpenAI::Internal::Type::Converter.coerce(M7, {a: err})
240+
assert_raises(OpenAI::Errors::ConversionError) do
241+
m2.a
242+
end
243+
end
221244
end

0 commit comments

Comments
 (0)