open_router_enhanced 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +1 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +13 -0
  5. data/.rubocop_todo.yml +130 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +41 -0
  8. data/CODE_OF_CONDUCT.md +84 -0
  9. data/CONTRIBUTING.md +384 -0
  10. data/Gemfile +22 -0
  11. data/Gemfile.lock +138 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +556 -0
  14. data/README.md +1660 -0
  15. data/Rakefile +334 -0
  16. data/SECURITY.md +150 -0
  17. data/VCR_CONFIGURATION.md +80 -0
  18. data/docs/model_selection.md +637 -0
  19. data/docs/observability.md +430 -0
  20. data/docs/prompt_templates.md +422 -0
  21. data/docs/streaming.md +467 -0
  22. data/docs/structured_outputs.md +466 -0
  23. data/docs/tools.md +1016 -0
  24. data/examples/basic_completion.rb +122 -0
  25. data/examples/model_selection_example.rb +141 -0
  26. data/examples/observability_example.rb +199 -0
  27. data/examples/prompt_template_example.rb +184 -0
  28. data/examples/smart_completion_example.rb +89 -0
  29. data/examples/streaming_example.rb +176 -0
  30. data/examples/structured_outputs_example.rb +191 -0
  31. data/examples/tool_calling_example.rb +149 -0
  32. data/lib/open_router/client.rb +552 -0
  33. data/lib/open_router/http.rb +118 -0
  34. data/lib/open_router/json_healer.rb +263 -0
  35. data/lib/open_router/model_registry.rb +378 -0
  36. data/lib/open_router/model_selector.rb +462 -0
  37. data/lib/open_router/prompt_template.rb +290 -0
  38. data/lib/open_router/response.rb +371 -0
  39. data/lib/open_router/schema.rb +288 -0
  40. data/lib/open_router/streaming_client.rb +210 -0
  41. data/lib/open_router/tool.rb +221 -0
  42. data/lib/open_router/tool_call.rb +180 -0
  43. data/lib/open_router/usage_tracker.rb +277 -0
  44. data/lib/open_router/version.rb +5 -0
  45. data/lib/open_router.rb +123 -0
  46. data/sig/open_router.rbs +20 -0
  47. metadata +186 -0
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "json-schema"
5
+ rescue LoadError
6
+ # json-schema gem not available
7
+ end
8
+
9
+ module OpenRouter
10
+ class SchemaValidationError < Error; end
11
+
12
+ class Schema
13
+ attr_reader :name, :strict, :schema
14
+
15
+ def initialize(name, schema_definition = {}, strict: true)
16
+ @name = name
17
+ @strict = strict
18
+ raise ArgumentError, "Schema definition must be a hash" unless schema_definition.is_a?(Hash)
19
+
20
+ @schema = schema_definition
21
+ validate_schema!
22
+ end
23
+
24
+ # Class method for defining schemas with a DSL
25
+ def self.define(name, strict: true, &block)
26
+ builder = SchemaBuilder.new
27
+ builder.instance_eval(&block) if block_given?
28
+ new(name, builder.to_h, strict:)
29
+ end
30
+
31
+ # Convert to the format expected by OpenRouter API
32
+ def to_h
33
+ # Apply OpenRouter-specific transformations
34
+ openrouter_schema = @schema.dup
35
+
36
+ # OpenRouter/Azure requires ALL properties to be in the required array
37
+ # even if they are logically optional. This is a deviation from JSON Schema spec
38
+ # but necessary for compatibility.
39
+ if openrouter_schema[:properties]&.any?
40
+ all_properties = openrouter_schema[:properties].keys.map(&:to_s)
41
+ openrouter_schema[:required] = all_properties
42
+ end
43
+
44
+ {
45
+ name: @name,
46
+ strict: @strict,
47
+ schema: openrouter_schema
48
+ }
49
+ end
50
+
51
+ # Get the pure JSON Schema (respects required flags) for testing/validation
52
+ def pure_schema
53
+ @schema
54
+ end
55
+
56
+ def to_json(*args)
57
+ to_h.to_json(*args)
58
+ end
59
+
60
+ # Check if JSON schema validation is available
61
+ def validation_available?
62
+ !!defined?(JSON::Validator)
63
+ end
64
+
65
+ # Validate data against this schema
66
+ def validate(data)
67
+ return true unless defined?(JSON::Validator)
68
+
69
+ JSON::Validator.validate(@schema, data)
70
+ end
71
+
72
+ # Get validation errors for data
73
+ def validation_errors(data)
74
+ return [] unless defined?(JSON::Validator)
75
+
76
+ JSON::Validator.fully_validate(@schema, data)
77
+ end
78
+
79
+ # Generate format instructions for model prompting
80
+ def get_format_instructions(forced: false)
81
+ schema_json = to_h.to_json
82
+
83
+ if forced
84
+ <<~INSTRUCTIONS
85
+ You must format your output as a JSON value that conforms exactly to the following JSON Schema specification:
86
+
87
+ #{schema_json}
88
+
89
+ CRITICAL: Your entire response must be valid JSON that matches this schema. Do not include any text before or after the JSON. Return ONLY the JSON value itself - no other text, explanations, or formatting.
90
+
91
+ example format:
92
+ ```json
93
+ {"field1": "value1", "field2": "value2"}
94
+ ```
95
+
96
+ Important guidelines:
97
+ - Ensure all required fields match the schema exactly
98
+ - Use proper JSON formatting (no trailing commas)
99
+ - All string values must be properly quoted
100
+ INSTRUCTIONS
101
+ else
102
+ <<~INSTRUCTIONS
103
+ Please format your output as a JSON value that conforms to the following JSON Schema specification:
104
+
105
+ #{schema_json}
106
+
107
+ Your response should be valid JSON that matches this schema structure exactly.
108
+
109
+ example format:
110
+ ```json
111
+ {"field1": "value1", "field2": "value2"}
112
+ ```
113
+
114
+ Important guidelines:
115
+ - Ensure all required fields match the schema
116
+ - Use proper JSON formatting (no trailing commas)
117
+ - Return ONLY the JSON - no other text or explanations
118
+ INSTRUCTIONS
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def validate_schema!
125
+ raise ArgumentError, "Schema name is required" if @name.nil? || @name.empty?
126
+ raise ArgumentError, "Schema must be a hash" unless @schema.is_a?(Hash)
127
+ end
128
+
129
+ # Internal class for building schemas with DSL
130
+ class SchemaBuilder
131
+ def initialize
132
+ @schema = {
133
+ type: "object",
134
+ properties: {},
135
+ required: []
136
+ }
137
+ @strict_mode = true
138
+ # Set additionalProperties to false by default in strict mode
139
+ @schema[:additionalProperties] = false
140
+ end
141
+
142
+ def strict(value = true)
143
+ @strict_mode = value
144
+ additional_properties(!value)
145
+ end
146
+
147
+ def additional_properties(allowed = true)
148
+ @schema[:additionalProperties] = allowed
149
+ end
150
+
151
+ def no_additional_properties
152
+ additional_properties(false)
153
+ end
154
+
155
+ def property(name, type, required: false, description: nil, **options)
156
+ prop_def = { type: type.to_s }
157
+ prop_def[:description] = description if description
158
+ prop_def.merge!(options)
159
+
160
+ @schema[:properties][name] = prop_def
161
+ mark_required(name) if required
162
+ end
163
+
164
+ def string(name, required: false, description: nil, **options)
165
+ property(name, :string, required:, description:, **options)
166
+ end
167
+
168
+ def integer(name, required: false, description: nil, **options)
169
+ property(name, :integer, required:, description:, **options)
170
+ end
171
+
172
+ def number(name, required: false, description: nil, **options)
173
+ property(name, :number, required:, description:, **options)
174
+ end
175
+
176
+ def boolean(name, required: false, description: nil, **options)
177
+ property(name, :boolean, required:, description:, **options)
178
+ end
179
+
180
+ def array(name, required: false, description: nil, items: nil, &block)
181
+ array_def = { type: "array" }
182
+ array_def[:description] = description if description
183
+
184
+ if items
185
+ array_def[:items] = items
186
+ elsif block_given?
187
+ items_builder = ItemsBuilder.new
188
+ items_builder.instance_eval(&block)
189
+ array_def[:items] = items_builder.to_h
190
+ end
191
+
192
+ @schema[:properties][name] = array_def
193
+ mark_required(name) if required
194
+ end
195
+
196
+ def object(name, required: false, description: nil, &block)
197
+ object_def = {
198
+ type: "object",
199
+ properties: {},
200
+ required: []
201
+ }
202
+ object_def[:description] = description if description
203
+
204
+ if block_given?
205
+ object_builder = SchemaBuilder.new
206
+ object_builder.instance_eval(&block)
207
+ nested_schema = object_builder.to_h
208
+ object_def[:properties] = nested_schema[:properties]
209
+ object_def[:required] = nested_schema[:required]
210
+ if nested_schema.key?(:additionalProperties)
211
+ object_def[:additionalProperties] =
212
+ nested_schema[:additionalProperties]
213
+ end
214
+ end
215
+
216
+ @schema[:properties][name] = object_def
217
+ mark_required(name) if required
218
+ end
219
+
220
+ def required(*field_names)
221
+ field_names.each { |name| mark_required(name) }
222
+ end
223
+
224
+ def to_h
225
+ @schema.dup
226
+ end
227
+
228
+ private
229
+
230
+ def mark_required(name)
231
+ # Convert to string to match OpenRouter API expectations
232
+ string_name = name.to_s
233
+ @schema[:required] << string_name unless @schema[:required].include?(string_name)
234
+ end
235
+ end
236
+
237
+ # Internal class for building array items
238
+ class ItemsBuilder
239
+ def initialize
240
+ @items = {}
241
+ end
242
+
243
+ def string(description: nil, **options)
244
+ @items = { type: "string" }
245
+ @items[:description] = description if description
246
+ @items.merge!(options)
247
+ end
248
+
249
+ def integer(description: nil, **options)
250
+ @items = { type: "integer" }
251
+ @items[:description] = description if description
252
+ @items.merge!(options)
253
+ end
254
+
255
+ def number(description: nil, **options)
256
+ @items = { type: "number" }
257
+ @items[:description] = description if description
258
+ @items.merge!(options)
259
+ end
260
+
261
+ def boolean(description: nil, **options)
262
+ @items = { type: "boolean" }
263
+ @items[:description] = description if description
264
+ @items.merge!(options)
265
+ end
266
+
267
+ def object(&block)
268
+ @items = { type: "object", properties: {}, required: [], additionalProperties: false }
269
+
270
+ return unless block_given?
271
+
272
+ object_builder = SchemaBuilder.new
273
+ object_builder.instance_eval(&block)
274
+ nested_schema = object_builder.to_h
275
+ @items[:properties] = nested_schema[:properties]
276
+ @items[:required] = nested_schema[:required]
277
+ return unless nested_schema.key?(:additionalProperties)
278
+
279
+ @items[:additionalProperties] =
280
+ nested_schema[:additionalProperties]
281
+ end
282
+
283
+ def to_h
284
+ @items
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Enhanced streaming client with better event handling and response reconstruction
5
+ class StreamingClient < Client
6
+ # Initialize streaming client with additional streaming-specific options
7
+ def initialize(*args, **kwargs, &block)
8
+ super(*args, **kwargs, &block)
9
+ @streaming_callbacks = {
10
+ on_chunk: [],
11
+ on_start: [],
12
+ on_finish: [],
13
+ on_tool_call_chunk: [],
14
+ on_error: []
15
+ }
16
+ end
17
+
18
+ # Register streaming-specific callbacks
19
+ #
20
+ # @param event [Symbol] The streaming event to register for
21
+ # @param block [Proc] The callback to execute
22
+ # @return [self] Returns self for method chaining
23
+ def on_stream(event, &block)
24
+ unless @streaming_callbacks.key?(event)
25
+ valid_events = @streaming_callbacks.keys.join(", ")
26
+ raise ArgumentError, "Invalid streaming event: #{event}. Valid events are: #{valid_events}"
27
+ end
28
+
29
+ @streaming_callbacks[event] << block
30
+ self
31
+ end
32
+
33
+ # Enhanced streaming completion with better event handling and response reconstruction
34
+ #
35
+ # @param messages [Array<Hash>] Array of message hashes
36
+ # @param model [String|Array] Model identifier or array of models for fallback
37
+ # @param accumulate_response [Boolean] Whether to accumulate and return complete response
38
+ # @param extras [Hash] Additional parameters for the completion request
39
+ # @return [Response, nil] Complete response if accumulate_response is true, nil otherwise
40
+ def stream_complete(messages, model: "openrouter/auto", accumulate_response: true, **extras)
41
+ response_accumulator = ResponseAccumulator.new if accumulate_response
42
+
43
+ # Set up streaming handler
44
+ stream_handler = build_stream_handler(response_accumulator)
45
+
46
+ # Trigger start callback
47
+ trigger_streaming_callbacks(:on_start, { model: model, messages: messages })
48
+
49
+ begin
50
+ # Execute the streaming request
51
+ complete(messages, model: model, stream: stream_handler, **extras)
52
+
53
+ # Return accumulated response if requested
54
+ if accumulate_response && response_accumulator
55
+ final_response = response_accumulator.build_response
56
+ trigger_streaming_callbacks(:on_finish, final_response)
57
+ final_response
58
+ else
59
+ trigger_streaming_callbacks(:on_finish, nil)
60
+ nil
61
+ end
62
+ rescue StandardError => e
63
+ trigger_streaming_callbacks(:on_error, e)
64
+ raise
65
+ end
66
+ end
67
+
68
+ # Stream with a simple block interface
69
+ #
70
+ # @param messages [Array<Hash>] Array of message hashes
71
+ # @param model [String|Array] Model identifier
72
+ # @param block [Proc] Block to call for each content chunk
73
+ # @param extras [Hash] Additional parameters
74
+ #
75
+ # @example
76
+ # client.stream(messages, model: "openai/gpt-4o-mini") do |chunk|
77
+ # print chunk
78
+ # end
79
+ def stream(messages, model: "openrouter/auto", **extras, &block)
80
+ raise ArgumentError, "Block required for streaming" unless block_given?
81
+
82
+ stream_complete(
83
+ messages,
84
+ model: model,
85
+ accumulate_response: false,
86
+ **extras
87
+ ) do |chunk|
88
+ content = extract_content_from_chunk(chunk)
89
+ block.call(content) if content
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def build_stream_handler(accumulator)
96
+ proc do |chunk|
97
+ # Trigger chunk callback
98
+ trigger_streaming_callbacks(:on_chunk, chunk)
99
+
100
+ # Accumulate if needed
101
+ accumulator&.add_chunk(chunk)
102
+
103
+ # Handle tool call chunks
104
+ trigger_streaming_callbacks(:on_tool_call_chunk, chunk) if chunk.dig("choices", 0, "delta", "tool_calls")
105
+
106
+ # Also trigger general stream callbacks for backward compatibility
107
+ trigger_callbacks(:on_stream_chunk, chunk)
108
+ rescue StandardError => e
109
+ trigger_streaming_callbacks(:on_error, e)
110
+ end
111
+ end
112
+
113
+ def trigger_streaming_callbacks(event, data)
114
+ return unless @streaming_callbacks[event]
115
+
116
+ @streaming_callbacks[event].each do |callback|
117
+ callback.call(data)
118
+ rescue StandardError => e
119
+ warn "[OpenRouter] Streaming callback error for #{event}: #{e.message}"
120
+ end
121
+ end
122
+
123
+ def extract_content_from_chunk(chunk)
124
+ chunk.dig("choices", 0, "delta", "content")
125
+ end
126
+ end
127
+
128
+ # Accumulates streaming chunks to reconstruct a complete response
129
+ class ResponseAccumulator
130
+ def initialize
131
+ @chunks = []
132
+ @content_parts = []
133
+ @tool_calls = {}
134
+ @first_chunk = nil
135
+ @last_chunk = nil
136
+ end
137
+
138
+ # Add a streaming chunk
139
+ def add_chunk(chunk)
140
+ @chunks << chunk
141
+ @first_chunk ||= chunk
142
+ @last_chunk = chunk
143
+
144
+ process_chunk(chunk)
145
+ end
146
+
147
+ # Build final response object
148
+ def build_response
149
+ return nil if @chunks.empty?
150
+
151
+ # Build the complete response structure
152
+ response_data = build_response_structure
153
+
154
+ Response.new(response_data)
155
+ end
156
+
157
+ private
158
+
159
+ def process_chunk(chunk)
160
+ delta = chunk.dig("choices", 0, "delta")
161
+ return unless delta
162
+
163
+ # Accumulate content
164
+ if (content = delta["content"])
165
+ @content_parts << content
166
+ end
167
+
168
+ # Accumulate tool calls
169
+ if (tool_calls = delta["tool_calls"])
170
+ tool_calls.each do |tc|
171
+ index = tc["index"]
172
+ @tool_calls[index] ||= {
173
+ "id" => tc["id"],
174
+ "type" => tc["type"],
175
+ "function" => { "name" => "", "arguments" => "" }
176
+ }
177
+
178
+ @tool_calls[index]["function"]["name"] = tc["function"]["name"] if tc.dig("function", "name")
179
+
180
+ @tool_calls[index]["function"]["arguments"] += tc["function"]["arguments"] if tc.dig("function", "arguments")
181
+ end
182
+ end
183
+ end
184
+
185
+ def build_response_structure
186
+ choice = {
187
+ "index" => 0,
188
+ "message" => {
189
+ "role" => "assistant",
190
+ "content" => @content_parts.join
191
+ },
192
+ "finish_reason" => @last_chunk.dig("choices", 0, "finish_reason")
193
+ }
194
+
195
+ # Add tool calls if present
196
+ choice["message"]["tool_calls"] = @tool_calls.values unless @tool_calls.empty?
197
+
198
+ {
199
+ "id" => @first_chunk["id"],
200
+ "object" => @first_chunk["object"],
201
+ "created" => @first_chunk["created"],
202
+ "model" => @first_chunk["model"],
203
+ "choices" => [choice],
204
+ "usage" => @last_chunk["usage"],
205
+ "provider" => @first_chunk["provider"],
206
+ "system_fingerprint" => @first_chunk["system_fingerprint"]
207
+ }.compact
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ class Tool
5
+ attr_reader :type, :function
6
+
7
+ def initialize(definition = {})
8
+ if definition.is_a?(Hash) && definition.key?(:function)
9
+ @type = definition[:type] || "function"
10
+ @function = definition[:function]
11
+ elsif definition.is_a?(Hash)
12
+ @type = "function"
13
+ @function = definition
14
+ else
15
+ raise ArgumentError, "Tool definition must be a hash"
16
+ end
17
+
18
+ validate_definition!
19
+ end
20
+
21
+ # Class method for defining tools with a DSL
22
+ def self.define(&block)
23
+ builder = ToolBuilder.new
24
+ builder.instance_eval(&block) if block_given?
25
+ new(builder.to_h)
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ type: @type,
31
+ function: @function
32
+ }
33
+ end
34
+
35
+ def to_json(*args)
36
+ to_h.to_json(*args)
37
+ end
38
+
39
+ def name
40
+ @function[:name]
41
+ end
42
+
43
+ def description
44
+ @function[:description]
45
+ end
46
+
47
+ def parameters
48
+ @function[:parameters]
49
+ end
50
+
51
+ private
52
+
53
+ def validate_definition!
54
+ raise ArgumentError, "Function must have a name" unless @function[:name]
55
+ raise ArgumentError, "Function must have a description" unless @function[:description]
56
+
57
+ return unless @function[:parameters] && @function[:parameters][:type] != "object"
58
+
59
+ raise ArgumentError,
60
+ "Function parameters must be an object"
61
+ end
62
+
63
+ # Internal class for building tool definitions with DSL
64
+ class ToolBuilder
65
+ def initialize
66
+ @definition = {
67
+ name: nil,
68
+ description: nil,
69
+ parameters: {
70
+ type: "object",
71
+ properties: {},
72
+ required: []
73
+ }
74
+ }
75
+ end
76
+
77
+ def name(value)
78
+ @definition[:name] = value
79
+ end
80
+
81
+ def description(value)
82
+ @definition[:description] = value
83
+ end
84
+
85
+ def parameters(&block)
86
+ param_builder = ParametersBuilder.new(@definition[:parameters])
87
+ param_builder.instance_eval(&block) if block_given?
88
+ end
89
+
90
+ def to_h
91
+ @definition
92
+ end
93
+ end
94
+
95
+ # Internal class for building parameter schemas
96
+ class ParametersBuilder
97
+ def initialize(params_hash)
98
+ @params = params_hash
99
+ end
100
+
101
+ def string(name, required: false, description: nil, **options)
102
+ add_property(name, { type: "string", description: }.merge(options).compact)
103
+ mark_required(name) if required
104
+ end
105
+
106
+ def integer(name, required: false, description: nil, **options)
107
+ add_property(name, { type: "integer", description: }.merge(options).compact)
108
+ mark_required(name) if required
109
+ end
110
+
111
+ def number(name, required: false, description: nil, **options)
112
+ add_property(name, { type: "number", description: }.merge(options).compact)
113
+ mark_required(name) if required
114
+ end
115
+
116
+ def boolean(name, required: false, description: nil, **options)
117
+ add_property(name, { type: "boolean", description: }.merge(options).compact)
118
+ mark_required(name) if required
119
+ end
120
+
121
+ def array(name, required: false, description: nil, items: nil, &block)
122
+ array_def = { type: "array", description: }.compact
123
+
124
+ if items
125
+ array_def[:items] = items
126
+ elsif block_given?
127
+ items_builder = ItemsBuilder.new
128
+ items_builder.instance_eval(&block)
129
+ array_def[:items] = items_builder.to_h
130
+ end
131
+
132
+ add_property(name, array_def)
133
+ mark_required(name) if required
134
+ end
135
+
136
+ def object(name, required: false, description: nil, &block)
137
+ object_def = {
138
+ type: "object",
139
+ description:,
140
+ properties: {},
141
+ required: []
142
+ }.compact
143
+
144
+ if block_given?
145
+ object_builder = ParametersBuilder.new(object_def)
146
+ object_builder.instance_eval(&block)
147
+ end
148
+
149
+ add_property(name, object_def)
150
+ mark_required(name) if required
151
+ end
152
+
153
+ private
154
+
155
+ def add_property(name, definition)
156
+ processed_definition = definition.transform_values { |value| value.is_a?(Proc) ? value.call : value }
157
+ @params[:properties][name] = processed_definition
158
+ end
159
+
160
+ def mark_required(name)
161
+ @params[:required] << name unless @params[:required].include?(name)
162
+ end
163
+ end
164
+
165
+ # Internal class for building array items
166
+ class ItemsBuilder
167
+ def initialize
168
+ @items = {}
169
+ end
170
+
171
+ def string(description: nil, **options)
172
+ @items = { type: "string", description: }.merge(options).compact
173
+ end
174
+
175
+ def integer(description: nil, **options)
176
+ @items = { type: "integer", description: }.merge(options).compact
177
+ end
178
+
179
+ def number(description: nil, **options)
180
+ @items = { type: "number", description: }.merge(options).compact
181
+ end
182
+
183
+ def boolean(description: nil, **options)
184
+ @items = { type: "boolean", description: }.merge(options).compact
185
+ end
186
+
187
+ def object(&block)
188
+ @items = {
189
+ type: "object",
190
+ properties: {},
191
+ required: [],
192
+ additionalProperties: false
193
+ }
194
+
195
+ return unless block_given?
196
+
197
+ nested = Tool::ParametersBuilder.new(@items)
198
+ nested.instance_eval(&block)
199
+ end
200
+
201
+ def items(schema = nil, &block)
202
+ # This method allows for `array { items { object { ... }}}` or `items(hash)`
203
+ if block_given?
204
+ # Block defines what each item in the array looks like
205
+ nested_builder = ItemsBuilder.new
206
+ nested_builder.instance_eval(&block)
207
+ @items = nested_builder.to_h
208
+ elsif schema.is_a?(Hash)
209
+ # Direct hash schema assignment
210
+ @items = schema
211
+ else
212
+ raise ArgumentError, "items must be called with either a hash or a block"
213
+ end
214
+ end
215
+
216
+ def to_h
217
+ @items
218
+ end
219
+ end
220
+ end
221
+ end