llm_ruby 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,107 +3,111 @@
3
3
  require "httparty"
4
4
  require "event_stream_parser"
5
5
 
6
- class LLM::Clients::OpenAI
7
- include HTTParty
8
- base_uri "https://api.openai.com/v1"
6
+ class LLM
7
+ module Clients
8
+ class OpenAI
9
+ include HTTParty
10
+ base_uri "https://api.openai.com/v1"
11
+
12
+ def initialize(llm:)
13
+ @llm = llm
14
+ end
9
15
 
10
- def initialize(llm:)
11
- @llm = llm
12
- end
16
+ def chat(messages, options = {})
17
+ parameters = {
18
+ model: @llm.canonical_name,
19
+ messages: messages,
20
+ temperature: options[:temperature],
21
+ response_format: options[:response_format]&.response_format,
22
+ max_tokens: options[:max_output_tokens],
23
+ top_p: options[:top_p],
24
+ stop: options[:stop_sequences],
25
+ presence_penalty: options[:presence_penalty],
26
+ frequency_penalty: options[:frequency_penalty],
27
+ tools: options[:tools],
28
+ tool_choice: options[:tool_choice]
29
+ }.compact
30
+
31
+ return chat_streaming(parameters, options[:on_message], options[:on_complete]) if options[:stream]
32
+
33
+ resp = post_url("/chat/completions", body: parameters.to_json)
34
+
35
+ Response.new(resp).to_normalized_response
36
+ end
13
37
 
14
- def chat(messages, options = {})
15
- parameters = {
16
- model: @llm.canonical_name,
17
- messages: messages,
18
- temperature: options[:temperature],
19
- response_format: options[:response_format],
20
- max_tokens: options[:max_output_tokens],
21
- top_p: options[:top_p],
22
- stop: options[:stop_sequences],
23
- presence_penalty: options[:presence_penalty],
24
- frequency_penalty: options[:frequency_penalty],
25
- tools: options[:tools],
26
- tool_choice: options[:tool_choice]
27
- }.compact
28
-
29
- return chat_streaming(parameters, options[:on_message], options[:on_complete]) if options[:stream]
30
-
31
- resp = post_url("/chat/completions", body: parameters.to_json)
32
-
33
- Response.new(resp).to_normalized_response
34
- end
38
+ private
35
39
 
36
- private
40
+ def chat_streaming(parameters, on_message, on_complete)
41
+ buffer = +""
42
+ chunks = []
43
+ output_data = {}
37
44
 
38
- def chat_streaming(parameters, on_message, on_complete)
39
- buffer = +""
40
- chunks = []
41
- output_data = {}
45
+ wrapped_on_complete = lambda { |stop_reason|
46
+ output_data[:stop_reason] = stop_reason
47
+ on_complete&.call(stop_reason)
48
+ }
42
49
 
43
- wrapped_on_complete = lambda { |stop_reason|
44
- output_data[:stop_reason] = stop_reason
45
- on_complete&.call(stop_reason)
46
- }
50
+ parameters[:stream] = true
47
51
 
48
- parameters[:stream] = true
52
+ proc = stream_proc(buffer, chunks, on_message, wrapped_on_complete)
49
53
 
50
- proc = stream_proc(buffer, chunks, on_message, wrapped_on_complete)
54
+ parameters.delete(:on_message)
55
+ parameters.delete(:on_complete)
51
56
 
52
- parameters.delete(:on_message)
53
- parameters.delete(:on_complete)
57
+ _resp = post_url_streaming("/chat/completions", body: parameters.to_json, &proc)
54
58
 
55
- _resp = post_url_streaming("/chat/completions", body: parameters.to_json, &proc)
59
+ LLM::Response.new(
60
+ content: buffer,
61
+ raw_response: chunks,
62
+ stop_reason: output_data[:stop_reason]
63
+ )
64
+ end
56
65
 
57
- LLM::Response.new(
58
- content: buffer,
59
- raw_response: chunks,
60
- stop_reason: output_data[:stop_reason]
61
- )
62
- end
66
+ def stream_proc(buffer, chunks, on_message, complete_proc)
67
+ each_json_chunk do |_type, event|
68
+ next if event == "[DONE]"
63
69
 
64
- def stream_proc(buffer, chunks, on_message, complete_proc)
65
- each_json_chunk do |_type, event|
66
- next if event == "[DONE]"
70
+ chunks << event
71
+ new_content = event.dig("choices", 0, "delta", "content")
72
+ stop_reason = event.dig("choices", 0, "finish_reason")
67
73
 
68
- chunks << event
69
- new_content = event.dig("choices", 0, "delta", "content")
70
- stop_reason = event.dig("choices", 0, "finish_reason")
74
+ buffer << new_content unless new_content.nil?
75
+ on_message&.call(new_content) unless new_content.nil?
76
+ complete_proc&.call(Response.normalize_stop_reason(stop_reason)) unless stop_reason.nil?
77
+ end
78
+ end
71
79
 
72
- buffer << new_content unless new_content.nil?
73
- on_message&.call(new_content) unless new_content.nil?
74
- complete_proc&.call(Response.normalize_stop_reason(stop_reason)) unless stop_reason.nil?
75
- end
76
- end
80
+ def each_json_chunk
81
+ parser = EventStreamParser::Parser.new
77
82
 
78
- def each_json_chunk
79
- parser = EventStreamParser::Parser.new
83
+ proc do |chunk, _bytes, env|
84
+ if env && env.status != 200
85
+ raise_error = Faraday::Response::RaiseError.new
86
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
87
+ end
80
88
 
81
- proc do |chunk, _bytes, env|
82
- if env && env.status != 200
83
- raise_error = Faraday::Response::RaiseError.new
84
- raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
85
- end
89
+ parser.feed(chunk) do |type, data|
90
+ next if data == "[DONE]"
86
91
 
87
- parser.feed(chunk) do |type, data|
88
- next if data == "[DONE]"
89
-
90
- yield(type, JSON.parse(data))
92
+ yield(type, JSON.parse(data))
93
+ end
94
+ end
91
95
  end
92
- end
93
- end
94
96
 
95
- def post_url(url, **kwargs)
96
- self.class.post(url, **kwargs.merge(headers: default_headers))
97
- end
97
+ def post_url(url, **kwargs)
98
+ self.class.post(url, **kwargs.merge(headers: default_headers))
99
+ end
98
100
 
99
- def post_url_streaming(url, **kwargs, &block)
100
- self.class.post(url, **kwargs.merge(headers: default_headers, stream_body: true), &block)
101
- end
101
+ def post_url_streaming(url, **kwargs, &block)
102
+ self.class.post(url, **kwargs.merge(headers: default_headers, stream_body: true), &block)
103
+ end
102
104
 
103
- def default_headers
104
- {
105
- "Authorization" => "Bearer #{ENV["OPENAI_API_KEY"]}",
106
- "Content-Type" => "application/json"
107
- }
105
+ def default_headers
106
+ {
107
+ "Authorization" => "Bearer #{ENV["OPENAI_API_KEY"]}",
108
+ "Content-Type" => "application/json"
109
+ }
110
+ end
111
+ end
108
112
  end
109
113
  end
data/lib/llm/info.rb CHANGED
@@ -1,94 +1,266 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Info
4
- KNOWN_MODELS = [
5
- # Semantics of fields:
6
- # - canonical_name (required): A string that uniquely identifies the model.
7
- # We use this string as the public identifier when users choose this model via the API.
8
- # - display_name (required): A string that is displayed to the user when choosing this model via the UI.
3
+ class LLM
4
+ module Info
5
+ KNOWN_MODELS = [
6
+ # Semantics of fields:
7
+ # - canonical_name (required): A string that uniquely identifies the model.
8
+ # We use this string as the public identifier when users choose this model via the API.
9
+ # - display_name (required): A string that is displayed to the user when choosing this model via the UI.
10
+ # - client_class (required): The client class to be used for this model.
9
11
 
10
- # GPT-3.5 Turbo Models
11
- {
12
- canonical_name: "gpt-3.5-turbo",
13
- display_name: "GPT-3.5 Turbo",
14
- provider: :openai
15
- },
16
- {
17
- canonical_name: "gpt-3.5-turbo-0125",
18
- display_name: "GPT-3.5 Turbo 0125",
19
- provider: :openai
20
- },
21
- {
22
- canonical_name: "gpt-3.5-turbo-16k",
23
- display_name: "GPT-3.5 Turbo 16K",
24
- provider: :openai
25
- },
26
- {
27
- canonical_name: "gpt-3.5-turbo-1106",
28
- display_name: "GPT-3.5 Turbo 1106",
29
- provider: :openai
30
- },
12
+ # GPT-3.5 Turbo Models
13
+ {
14
+ canonical_name: "gpt-3.5-turbo",
15
+ display_name: "GPT-3.5 Turbo",
16
+ provider: :openai,
17
+ client_class: LLM::Clients::OpenAI
18
+ },
19
+ {
20
+ canonical_name: "gpt-3.5-turbo-0125",
21
+ display_name: "GPT-3.5 Turbo 0125",
22
+ provider: :openai,
23
+ client_class: LLM::Clients::OpenAI
24
+ },
25
+ {
26
+ canonical_name: "gpt-3.5-turbo-16k",
27
+ display_name: "GPT-3.5 Turbo 16K",
28
+ provider: :openai,
29
+ client_class: LLM::Clients::OpenAI
30
+ },
31
+ {
32
+ canonical_name: "gpt-3.5-turbo-1106",
33
+ display_name: "GPT-3.5 Turbo 1106",
34
+ provider: :openai,
35
+ client_class: LLM::Clients::OpenAI
36
+ },
31
37
 
32
- # GPT-4 Models
33
- {
34
- canonical_name: "gpt-4",
35
- display_name: "GPT-4",
36
- provider: :openai
37
- },
38
- {
39
- canonical_name: "gpt-4-32k",
40
- display_name: "GPT-4 32K",
41
- provider: :openai
42
- },
43
- {
44
- canonical_name: "gpt-4-1106-preview",
45
- display_name: "GPT-4 Turbo 1106",
46
- provider: :openai
47
- },
48
- {
49
- canonical_name: "gpt-4-turbo-2024-04-09",
50
- display_name: "GPT-4 Turbo 2024-04-09",
51
- provider: :openai
52
- },
53
- {
54
- canonical_name: "gpt-4-0125-preview",
55
- display_name: "GPT-4 Turbo 0125",
56
- provider: :openai
57
- },
58
- {
59
- canonical_name: "gpt-4-turbo-preview",
60
- display_name: "GPT-4 Turbo",
61
- provider: :openai
62
- },
63
- {
64
- canonical_name: "gpt-4-0613",
65
- display_name: "GPT-4 0613",
66
- provider: :openai
67
- },
68
- {
69
- canonical_name: "gpt-4-32k-0613",
70
- display_name: "GPT-4 32K 0613",
71
- provider: :openai
72
- },
73
- {
74
- canonical_name: "gpt-4o",
75
- display_name: "GPT-4o",
76
- provider: :openai
77
- },
78
- {
79
- canonical_name: "gpt-4o-mini",
80
- display_name: "GPT-4o Mini",
81
- provider: :openai
82
- },
83
- {
84
- canonical_name: "gpt-4o-2024-05-13",
85
- display_name: "GPT-4o 2024-05-13",
86
- provider: :openai
87
- },
88
- {
89
- canonical_name: "gpt-4o-2024-08-06",
90
- display_name: "GPT-4o 2024-08-06",
91
- provider: :openai
92
- }
93
- ].freeze
38
+ # GPT-4 Models
39
+ {
40
+ canonical_name: "gpt-4",
41
+ display_name: "GPT-4",
42
+ provider: :openai,
43
+ client_class: LLM::Clients::OpenAI
44
+ },
45
+ {
46
+ canonical_name: "gpt-4-1106-preview",
47
+ display_name: "GPT-4 Turbo 1106",
48
+ provider: :openai,
49
+ client_class: LLM::Clients::OpenAI
50
+ },
51
+ {
52
+ canonical_name: "gpt-4-turbo-2024-04-09",
53
+ display_name: "GPT-4 Turbo 2024-04-09",
54
+ provider: :openai,
55
+ client_class: LLM::Clients::OpenAI
56
+ },
57
+ {
58
+ canonical_name: "gpt-4-0125-preview",
59
+ display_name: "GPT-4 Turbo 0125",
60
+ provider: :openai,
61
+ client_class: LLM::Clients::OpenAI
62
+ },
63
+ {
64
+ canonical_name: "gpt-4-turbo-preview",
65
+ display_name: "GPT-4 Turbo",
66
+ provider: :openai,
67
+ client_class: LLM::Clients::OpenAI
68
+ },
69
+ {
70
+ canonical_name: "gpt-4-0613",
71
+ display_name: "GPT-4 0613",
72
+ provider: :openai,
73
+ client_class: LLM::Clients::OpenAI
74
+ },
75
+ {
76
+ canonical_name: "gpt-4o",
77
+ display_name: "GPT-4o",
78
+ provider: :openai,
79
+ client_class: LLM::Clients::OpenAI,
80
+ supports_structured_outputs: true
81
+ },
82
+ {
83
+ canonical_name: "gpt-4o-mini",
84
+ display_name: "GPT-4o Mini",
85
+ provider: :openai,
86
+ client_class: LLM::Clients::OpenAI,
87
+ supports_structured_outputs: true
88
+ },
89
+ {
90
+ canonical_name: "gpt-4o-mini-2024-07-18",
91
+ display_name: "GPT-4o Mini 2024-07-18",
92
+ provider: :openai,
93
+ client_class: LLM::Clients::OpenAI,
94
+ supports_structured_outputs: true
95
+ },
96
+ {
97
+ canonical_name: "gpt-4o-2024-05-13",
98
+ display_name: "GPT-4o 2024-05-13",
99
+ provider: :openai,
100
+ client_class: LLM::Clients::OpenAI
101
+ },
102
+ {
103
+ canonical_name: "gpt-4o-2024-08-06",
104
+ display_name: "GPT-4o 2024-08-06",
105
+ provider: :openai,
106
+ client_class: LLM::Clients::OpenAI,
107
+ supports_structured_outputs: true
108
+ },
109
+ {
110
+ canonical_name: "gpt-4o-2024-11-20",
111
+ display_name: "GPT-4o 2024-11-20",
112
+ provider: :openai,
113
+ client_class: LLM::Clients::OpenAI,
114
+ supports_structured_outputs: true
115
+ },
116
+ {
117
+ canonical_name: "chatgpt-4o-latest",
118
+ display_name: "ChatGPT 4o Latest",
119
+ provider: :openai,
120
+ client_class: LLM::Clients::OpenAI
121
+ },
122
+ {
123
+ canonical_name: "o1",
124
+ display_name: "o1",
125
+ provider: :openai,
126
+ client_class: LLM::Clients::OpenAI,
127
+ supports_structured_outputs: true
128
+ },
129
+ {
130
+ canonical_name: "o1-2024-12-17",
131
+ display_name: "o1 2024-12-17",
132
+ provider: :openai,
133
+ client_class: LLM::Clients::OpenAI,
134
+ supports_structured_outputs: true
135
+ },
136
+ {
137
+ canonical_name: "o1-preview",
138
+ display_name: "o1 Preview",
139
+ provider: :openai,
140
+ client_class: LLM::Clients::OpenAI
141
+ },
142
+ {
143
+ canonical_name: "o1-preview-2024-09-12",
144
+ display_name: "o1 Preview 2024-09-12",
145
+ provider: :openai,
146
+ client_class: LLM::Clients::OpenAI
147
+ },
148
+ {
149
+ canonical_name: "o1-mini",
150
+ display_name: "o1 Mini",
151
+ provider: :openai,
152
+ client_class: LLM::Clients::OpenAI
153
+ },
154
+ {
155
+ canonical_name: "o1-mini-2024-09-12",
156
+ display_name: "o1 Mini 2024-09-12",
157
+ provider: :openai,
158
+ client_class: LLM::Clients::OpenAI
159
+ },
160
+ {
161
+ canonical_name: "o3-mini",
162
+ display_name: "o3 Mini",
163
+ provider: :openai,
164
+ client_class: LLM::Clients::OpenAI,
165
+ supports_structured_outputs: true
166
+ },
167
+ {
168
+ canonical_name: "o3-mini-2025-01-31",
169
+ display_name: "o3 Mini 2025-01-31",
170
+ provider: :openai,
171
+ client_class: LLM::Clients::OpenAI,
172
+ supports_structured_outputs: true
173
+ },
174
+
175
+ # Anthropic Models
176
+ {
177
+ canonical_name: "claude-3-5-sonnet-20241022",
178
+ display_name: "Claude 3.5 Sonnet 2024-10-22",
179
+ provider: :anthropic,
180
+ client_class: LLM::Clients::Anthropic,
181
+ additional_default_required_parameters: {
182
+ max_output_tokens: 8192
183
+ }
184
+ },
185
+ {
186
+ canonical_name: "claude-3-5-haiku-20241022",
187
+ display_name: "Claude 3.5 Haiku 2024-10-22",
188
+ provider: :anthropic,
189
+ client_class: LLM::Clients::Anthropic,
190
+ additional_default_required_parameters: {
191
+ max_output_tokens: 8192
192
+ }
193
+ },
194
+ {
195
+ canonical_name: "claude-3-5-sonnet-20240620",
196
+ display_name: "Claude 3.5 Sonnet 2024-06-20",
197
+ provider: :anthropic,
198
+ client_class: LLM::Clients::Anthropic,
199
+ additional_default_required_parameters: {
200
+ max_output_tokens: 8192
201
+ }
202
+ },
203
+ {
204
+ canonical_name: "claude-3-opus-20240229",
205
+ display_name: "Claude 3.5 Opus 2024-02-29",
206
+ provider: :anthropic,
207
+ client_class: LLM::Clients::Anthropic,
208
+ additional_default_required_parameters: {
209
+ max_output_tokens: 4096
210
+ }
211
+ },
212
+ {
213
+ canonical_name: "claude-3-sonnet-20240229",
214
+ display_name: "Claude 3.5 Sonnet 2024-02-29",
215
+ provider: :anthropic,
216
+ client_class: LLM::Clients::Anthropic,
217
+ additional_default_required_parameters: {
218
+ max_output_tokens: 4096
219
+ }
220
+ },
221
+ {
222
+ canonical_name: "claude-3-haiku-20240307",
223
+ display_name: "Claude 3.5 Opus 2024-03-07",
224
+ provider: :anthropic,
225
+ client_class: LLM::Clients::Anthropic,
226
+ additional_default_required_parameters: {
227
+ max_output_tokens: 4096
228
+ }
229
+ },
230
+
231
+ # Google Models
232
+ {
233
+ canonical_name: "gemini-2.0-flash",
234
+ display_name: "Gemini 2.0 Flash",
235
+ provider: :google,
236
+ client_class: LLM::Clients::Gemini,
237
+ supports_structured_outputs: true
238
+ },
239
+ {
240
+ canonical_name: "gemini-2.0-flash-lite-preview-02-05",
241
+ display_name: "Gemini 2.0 Flash Lite Preview 02-05",
242
+ provider: :google,
243
+ client_class: LLM::Clients::Gemini,
244
+ supports_structured_outputs: true
245
+ },
246
+ {
247
+ canonical_name: "gemini-1.5-flash-8b",
248
+ display_name: "Gemini 1.5 Flash 8B",
249
+ provider: :google,
250
+ client_class: LLM::Clients::Gemini
251
+ },
252
+ {
253
+ canonical_name: "gemini-1.5-flash",
254
+ display_name: "Gemini 1.5 Flash",
255
+ provider: :google,
256
+ client_class: LLM::Clients::Gemini
257
+ },
258
+ {
259
+ canonical_name: "gemini-1.5-pro",
260
+ display_name: "Gemini 1.5 Pro",
261
+ provider: :google,
262
+ client_class: LLM::Clients::Gemini
263
+ }
264
+ ].freeze
265
+ end
94
266
  end
data/lib/llm/response.rb CHANGED
@@ -1,3 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- LLM::Response = Struct.new(:content, :raw_response, :stop_reason, keyword_init: true)
3
+ require "ostruct"
4
+
5
+ LLM::Response = Struct.new(:content, :raw_response, :stop_reason, :structured_output, keyword_init: true) do
6
+ def structured_output_object
7
+ return nil unless structured_output
8
+
9
+ OpenStruct.new(structured_output)
10
+ end
11
+ end
data/lib/llm/schema.rb ADDED
@@ -0,0 +1,75 @@
1
+ class LLM
2
+ class Schema
3
+ def initialize(name, schema)
4
+ @name = name
5
+ @schema = schema
6
+ end
7
+
8
+ def self.from_file(file_path)
9
+ new(File.basename(file_path, ".json"), JSON.parse(File.read(file_path)))
10
+ end
11
+
12
+ def response_format
13
+ {
14
+ type: "json_schema",
15
+ json_schema: {
16
+ name: @name,
17
+ strict: true,
18
+ schema: @schema
19
+ }
20
+ }
21
+ end
22
+
23
+ def gemini_response_format
24
+ transform_schema(@schema)
25
+ end
26
+
27
+ def transform_schema(schema)
28
+ # Initialize the result as an empty hash.
29
+ openapi_schema = {}
30
+
31
+ # Process the "type" field and handle nullability.
32
+ if schema.key?("type")
33
+ if schema["type"].is_a?(Array)
34
+ # Check for "null" in the type array to mark the schema as nullable.
35
+ if schema["type"].include?("null")
36
+ openapi_schema["nullable"] = true
37
+ # Remove "null" from the type array; if a single type remains, use that.
38
+ remaining_types = schema["type"] - ["null"]
39
+ openapi_schema["type"] = (remaining_types.size == 1) ? remaining_types.first : remaining_types
40
+ else
41
+ openapi_schema["type"] = schema["type"]
42
+ end
43
+ else
44
+ openapi_schema["type"] = schema["type"]
45
+ end
46
+ end
47
+
48
+ # Map simple fields directly: "format", "description", "enum", "maxItems", "minItems".
49
+ ["format", "description", "enum", "maxItems", "minItems"].each do |field|
50
+ openapi_schema[field] = schema[field] if schema.key?(field)
51
+ end
52
+
53
+ # Recursively process "properties" if present.
54
+ if schema.key?("properties") && schema["properties"].is_a?(Hash)
55
+ openapi_schema["properties"] = {}
56
+ schema["properties"].each do |prop, prop_schema|
57
+ openapi_schema["properties"][prop] = transform_schema(prop_schema)
58
+ end
59
+ end
60
+
61
+ # Copy "required" if present.
62
+ openapi_schema["required"] = schema["required"] if schema.key?("required")
63
+
64
+ # Copy "propertyOrdering" if present (non-standard field).
65
+ openapi_schema["propertyOrdering"] = schema["propertyOrdering"] if schema.key?("propertyOrdering")
66
+
67
+ # Recursively process "items" for array types.
68
+ if schema.key?("items")
69
+ openapi_schema["items"] = transform_schema(schema["items"])
70
+ end
71
+
72
+ openapi_schema
73
+ end
74
+ end
75
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::StopReason
4
- STOP = :stop
5
- SAFETY = :safety
6
- MAX_TOKENS_REACHED = :max_tokens
3
+ class LLM
4
+ module StopReason
5
+ STOP = :stop
6
+ SAFETY = :safety
7
+ MAX_TOKENS_REACHED = :max_tokens
8
+ STOP_SEQUENCE = :stop_sequence
7
9
 
8
- OTHER = :other
10
+ OTHER = :other
11
+ end
9
12
  end
data/lib/llm.rb CHANGED
@@ -13,7 +13,9 @@ class LLM
13
13
  @canonical_name = model[:canonical_name]
14
14
  @display_name = model[:display_name]
15
15
  @provider = model[:provider]
16
- @client_class = LLM::Clients::OpenAI # TODO: Allow alternative client classes.
16
+ @client_class = model[:client_class]
17
+ @default_params = model[:additional_default_required_parameters] || {}
18
+ @supports_structured_outputs = model[:supports_structured_outputs] || false
17
19
  end
18
20
 
19
21
  def client
@@ -22,7 +24,12 @@ class LLM
22
24
 
23
25
  attr_reader :canonical_name,
24
26
  :display_name,
25
- :provider
27
+ :provider,
28
+ :default_params
29
+
30
+ def supports_structured_outputs?
31
+ @supports_structured_outputs
32
+ end
26
33
 
27
34
  private
28
35