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.
- checksums.yaml +7 -0
- data/.env.example +1 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.rubocop_todo.yml +130 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +41 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +384 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +138 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +556 -0
- data/README.md +1660 -0
- data/Rakefile +334 -0
- data/SECURITY.md +150 -0
- data/VCR_CONFIGURATION.md +80 -0
- data/docs/model_selection.md +637 -0
- data/docs/observability.md +430 -0
- data/docs/prompt_templates.md +422 -0
- data/docs/streaming.md +467 -0
- data/docs/structured_outputs.md +466 -0
- data/docs/tools.md +1016 -0
- data/examples/basic_completion.rb +122 -0
- data/examples/model_selection_example.rb +141 -0
- data/examples/observability_example.rb +199 -0
- data/examples/prompt_template_example.rb +184 -0
- data/examples/smart_completion_example.rb +89 -0
- data/examples/streaming_example.rb +176 -0
- data/examples/structured_outputs_example.rb +191 -0
- data/examples/tool_calling_example.rb +149 -0
- data/lib/open_router/client.rb +552 -0
- data/lib/open_router/http.rb +118 -0
- data/lib/open_router/json_healer.rb +263 -0
- data/lib/open_router/model_registry.rb +378 -0
- data/lib/open_router/model_selector.rb +462 -0
- data/lib/open_router/prompt_template.rb +290 -0
- data/lib/open_router/response.rb +371 -0
- data/lib/open_router/schema.rb +288 -0
- data/lib/open_router/streaming_client.rb +210 -0
- data/lib/open_router/tool.rb +221 -0
- data/lib/open_router/tool_call.rb +180 -0
- data/lib/open_router/usage_tracker.rb +277 -0
- data/lib/open_router/version.rb +5 -0
- data/lib/open_router.rb +123 -0
- data/sig/open_router.rbs +20 -0
- 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
|