Skip to content

Commit e9910f7

Browse files
jayelkaakeEric-Guo
authored andcommitted
Implement working with_response_format for Open AI
1 parent a35aa11 commit e9910f7

19 files changed

+1263
-13
lines changed

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ build-iPhoneSimulator/
4747
# for a library or gem, you might want to ignore these files since the code is
4848
# intended to run in multiple environments; otherwise, check them in:
4949
Gemfile.lock
50-
# .ruby-version
51-
# .ruby-gemset
50+
.ruby-version
51+
.ruby-gemset
5252

5353
# unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
5454
.rvmrc
@@ -57,3 +57,4 @@ Gemfile.lock
5757
# .rubocop-https?--*
5858

5959
repomix-output.*
60+
/.idea/

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ chat.ask "Tell me a story about a Ruby programmer" do |chunk|
6060
print chunk.content
6161
end
6262

63+
# Get structured responses easily (OpenAI only for now)
64+
chat.with_response_format(:integer).ask("What is 2 + 2?").to_i # => 4
65+
6366
# Generate images
6467
RubyLLM.paint "a sunset over mountains in watercolor style"
6568

docs/guides/chat.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,54 @@ end
303303
chat.ask "What is metaprogramming in Ruby?"
304304
```
305305

306+
## Receiving Structured Responses
307+
You can ensure the responses follow a schema you define like this:
308+
```ruby
309+
chat = RubyLLM.chat
310+
311+
chat.with_response_format(:integer).ask("What is 2 + 2?").to_i
312+
# => 4
313+
314+
chat.with_response_format(:string).ask("Say 'Hello World' and nothing else.").content
315+
# => "Hello World"
316+
317+
chat.with_response_format(:array, items: { type: :string })
318+
chat.ask('What are the 2 largest countries? Only respond with country names.').content
319+
# => ["Russia", "Canada"]
320+
321+
chat.with_response_format(:object, properties: { age: { type: :integer } })
322+
chat.ask('Provide sample customer age between 10 and 100.').content
323+
# => { "age" => 42 }
324+
325+
chat.with_response_format(
326+
:object,
327+
properties: { hobbies: { type: :array, items: { type: :string, enum: %w[Soccer Golf Hockey] } } }
328+
)
329+
chat.ask('Provide at least 1 hobby.').content
330+
# => { "hobbies" => ["Soccer"] }
331+
```
332+
333+
You can also provide the JSON schema you want directly to the method like this:
334+
```ruby
335+
chat.with_response_format(type: :object, properties: { age: { type: :integer } })
336+
# => { "age" => 31 }
337+
```
338+
339+
In this example the code is automatically switching to OpenAI's json_mode since no object properties are requested:
340+
```ruby
341+
chat.with_response_format(:json) # Don't care about structure, just give me JSON
342+
343+
chat.ask('Provide a sample customer data object with name and email keys.').content
344+
# => { "name" => "Tobias", "email" => "[email protected]" }
345+
346+
chat.ask('Provide a sample customer data object with name and email keys.').content
347+
# => { "first_name" => "Michael", "email_address" => "[email protected]" }
348+
```
349+
350+
{: .note }
351+
**Only OpenAI supported for now:** Only OpenAI models support this feature for now. We will add support for other models shortly.
352+
353+
306354
## Next Steps
307355

308356
This guide covered the core `Chat` interface. Now you might want to explore:
@@ -311,4 +359,4 @@ This guide covered the core `Chat` interface. Now you might want to explore:
311359
* [Using Tools]({% link guides/tools.md %}): Enable the AI to call your Ruby code.
312360
* [Streaming Responses]({% link guides/streaming.md %}): Get real-time feedback from the AI.
313361
* [Rails Integration]({% link guides/rails.md %}): Persist your chat conversations easily.
314-
* [Error Handling]({% link guides/error-handling.md %}): Build robust applications that handle API issues.
362+
* [Error Handling]({% link guides/error-handling.md %}): Build robust applications that handle API issues.

lib/ruby_llm/active_record/acts_as.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ def with_instructions(instructions, replace: false)
105105
self
106106
end
107107

108+
# @see LlmChat#with_response_format
109+
def with_response_format(...)
110+
to_llm.with_response_format(...)
111+
self
112+
end
113+
108114
def with_tool(...)
109115
to_llm.with_tool(...)
110116
self
@@ -183,6 +189,7 @@ def persist_message_completion(message)
183189
output_tokens: message.output_tokens
184190
)
185191
@message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
192+
@message.try('content_schema=', message.content_schema)
186193
@message.save!
187194
persist_tool_calls(message.tool_calls) if message.tool_calls.present?
188195
end

lib/ruby_llm/chat.rb

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,56 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
3131
}
3232
end
3333

34+
##
35+
# This method lets you ensure the responses follow a schema you define like this:
36+
#
37+
# chat.with_response_format(:integer).ask("What is 2 + 2?").to_i
38+
# # => 4
39+
# chat.with_response_format(:string).ask("Say 'Hello World' and nothing else.").content
40+
# # => "Hello World"
41+
# chat.with_response_format(:array, items: { type: :string })
42+
# chat.ask('What are the 2 largest countries? Only respond with country names.').content
43+
# # => ["Russia", "Canada"]
44+
# chat.with_response_format(:object, properties: { age: { type: :integer } })
45+
# chat.ask('Provide sample customer age between 10 and 100.').content
46+
# # => { "age" => 42 }
47+
# chat.with_response_format(
48+
# :object,
49+
# properties: { hobbies: { type: :array, items: { type: :string, enum: %w[Soccer Golf Hockey] } } }
50+
# )
51+
# chat.ask('Provide at least 1 hobby.').content
52+
# # => { "hobbies" => ["Soccer"] }
53+
#
54+
# You can also provide the JSON schema you want directly to the method like this:
55+
# chat.with_response_format(type: :object, properties: { age: { type: :integer } })
56+
# # => { "age" => 31 }
57+
#
58+
# In this example the code is automatically switching to OpenAI's json_mode since no object
59+
# properties are requested:
60+
# chat.with_response_format(:json) # Don't care about structure, just give me JSON
61+
# chat.ask('Provide a sample customer data object with name and email keys.').content
62+
# # => { "name" => "Tobias", "email" => "[email protected]" }
63+
# chat.ask('Provide a sample customer data object with name and email keys.').content
64+
# # => { "first_name" => "Michael", "email_address" => "[email protected]" }
65+
#
66+
# @param type [Symbol] (optional) This can be anything supported by the API JSON schema types (integer, object, etc)
67+
# @param schema [Hash] The schema for the response format. It can be a JSON schema or a simple hash.
68+
# @return [Chat] (self)
69+
def with_response_format(type = nil, **schema)
70+
schema_hash = if type.is_a?(Symbol) || type.is_a?(String)
71+
{ type: type == :json ? :object : type }
72+
elsif type.is_a?(Hash)
73+
type
74+
else
75+
{}
76+
end.merge(schema)
77+
78+
@response_schema = Schema.new(schema_hash)
79+
80+
self
81+
end
82+
alias with_structured_response with_response_format
83+
3484
def ask(message = nil, with: nil, &)
3585
add_message role: :user, content: Content.new(message, with)
3686
complete(&)
@@ -94,17 +144,23 @@ def each(&)
94144

95145
def complete(&)
96146
@on[:new_message]&.call
97-
response = @provider.complete(
98-
messages,
99-
tools: @tools,
100-
temperature: @temperature,
101-
model: @model.id,
102-
connection: @connection,
103-
&
104-
)
147+
response = @provider.with_response_schema(@response_schema) do
148+
@provider.complete(
149+
messages,
150+
tools: @tools,
151+
temperature: @temperature,
152+
model: @model.id,
153+
connection: @connection,
154+
&
155+
)
156+
end
157+
105158
@on[:end_message]&.call(response)
106159

107160
add_message response
161+
162+
@response_schema = nil # Reset the response schema after completion of this chat thread
163+
108164
if response.tool_call?
109165
handle_tool_calls(response, &)
110166
else

lib/ruby_llm/message.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ module RubyLLM
77
class Message
88
ROLES = %i[system user assistant tool].freeze
99

10-
attr_reader :role, :tool_calls, :tool_call_id, :input_tokens, :output_tokens, :model_id
10+
attr_reader :role, :tool_calls, :tool_call_id, :input_tokens, :output_tokens, :model_id, :content_schema
11+
12+
delegate :to_i, :to_a, :to_s, to: :content
1113

1214
def initialize(options = {})
1315
@role = options.fetch(:role).to_sym
@@ -17,12 +19,15 @@ def initialize(options = {})
1719
@output_tokens = options[:output_tokens]
1820
@model_id = options[:model_id]
1921
@tool_call_id = options[:tool_call_id]
22+
@content_schema = options[:content_schema]
2023

2124
ensure_valid_role
2225
end
2326

2427
def content
25-
if @content.is_a?(Content) && @content.text && @content.attachments.empty?
28+
if @content_schema.present? && @content_schema[:type].to_s == :object.to_s
29+
@content_schema[:properties].to_h.keys.none? ? json_response : structured_content
30+
elsif @content.is_a?(Content) && @content.text && @content.attachments.empty?
2631
@content.text
2732
else
2833
@content
@@ -55,6 +60,18 @@ def to_h
5560

5661
private
5762

63+
def json_response
64+
return nil if @content.nil?
65+
66+
JSON.parse(@content.text)
67+
end
68+
69+
def structured_content
70+
return nil if @content.nil?
71+
72+
json_response['result']
73+
end
74+
5875
def normalize_content(content)
5976
case content
6077
when String then Content.new(content)

lib/ruby_llm/provider.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ def list_models(connection:)
3131
parse_list_models_response response, slug, capabilities
3232
end
3333

34+
##
35+
# @return [::RubyLLM::Schema, NilClass]
36+
def response_schema
37+
Thread.current['RubyLLM::Provider::Methods.response_schema']
38+
end
39+
40+
##
41+
# @param response_schema [::RubyLLM::Schema]
42+
def with_response_schema(response_schema)
43+
prev_response_schema = Thread.current['RubyLLM::Provider::Methods.response_schema']
44+
45+
result = nil
46+
begin
47+
Thread.current['RubyLLM::Provider::Methods.response_schema'] = response_schema
48+
49+
result = yield
50+
ensure
51+
Thread.current['RubyLLM::Provider::Methods.response_schema'] = prev_response_schema
52+
end
53+
54+
result
55+
end
56+
3457
def embed(text, model:, connection:, dimensions:)
3558
payload = render_embedding_payload(text, model:, dimensions:)
3659
response = connection.post(embedding_url(model:), payload)

lib/ruby_llm/providers/openai/chat.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def render_payload(messages, tools:, temperature:, model:, stream: false)
2626
payload[:tool_choice] = 'auto'
2727
end
2828

29+
add_response_schema_to_payload(payload) if response_schema.present?
30+
2931
payload[:stream_options] = { include_usage: true } if stream
3032
payload
3133
end
@@ -41,6 +43,7 @@ def parse_completion_response(response)
4143

4244
Message.new(
4345
role: :assistant,
46+
content_schema: response_schema,
4447
content: message_data['content'],
4548
tool_calls: parse_tool_calls(message_data['tool_calls']),
4649
input_tokens: data['usage']['prompt_tokens'],
@@ -68,6 +71,54 @@ def format_role(role)
6871
role.to_s
6972
end
7073
end
74+
75+
private
76+
77+
##
78+
# @param [Hash] payload
79+
def add_response_schema_to_payload(payload)
80+
payload[:response_format] = gen_response_format_request
81+
82+
return unless payload[:response_format][:type] == :json_object
83+
84+
# NOTE: this is required by the Open AI API when requesting arbitrary JSON.
85+
payload[:messages].unshift({ role: :developer, content: <<~GUIDANCE
86+
You must format your output as a valid JSON object.
87+
Format your entire response as valid JSON.
88+
Do not include explanations, markdown formatting, or any text outside the JSON.
89+
GUIDANCE
90+
})
91+
end
92+
93+
##
94+
# @return [Hash]
95+
def gen_response_format_request
96+
if response_schema[:type].to_s == :object.to_s && response_schema[:properties].to_h.keys.none?
97+
{ type: :json_object } # Assume we just want json_mode
98+
else
99+
gen_json_schema_format_request
100+
end
101+
end
102+
103+
def gen_json_schema_format_request # rubocop:disable Metrics/MethodLength -- because it's mostly the standard hash
104+
result_schema = response_schema.dup # so we don't modify the original in the thread
105+
result_schema.add_to_each_object_type!(:additionalProperties, false)
106+
result_schema.add_to_each_object_type!(:required, ->(schema) { schema[:properties].to_h.keys })
107+
108+
{
109+
type: :json_schema,
110+
json_schema: {
111+
name: :response,
112+
schema: {
113+
type: :object,
114+
properties: { result: result_schema.to_h },
115+
additionalProperties: false,
116+
required: [:result]
117+
},
118+
strict: true
119+
}
120+
}
121+
end
71122
end
72123
end
73124
end

0 commit comments

Comments
 (0)