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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# Main prompt template class that handles variable interpolation,
|
|
5
|
+
# few-shot examples, and chat message formatting
|
|
6
|
+
class PromptTemplate
|
|
7
|
+
attr_reader :template, :input_variables, :prefix, :suffix, :examples, :example_template
|
|
8
|
+
|
|
9
|
+
# Initialize a new PromptTemplate
|
|
10
|
+
#
|
|
11
|
+
# @param template [String, nil] Main template string with {variable} placeholders
|
|
12
|
+
# @param input_variables [Array<Symbol>] List of required input variables
|
|
13
|
+
# @param prefix [String, nil] Optional prefix text (for few-shot templates)
|
|
14
|
+
# @param suffix [String, nil] Optional suffix text (for few-shot templates)
|
|
15
|
+
# @param examples [Array<Hash>, nil] Optional examples for few-shot learning
|
|
16
|
+
# @param example_template [String, PromptTemplate, nil] Template for formatting examples
|
|
17
|
+
# @param partial_variables [Hash] Pre-filled variable values
|
|
18
|
+
#
|
|
19
|
+
# @example Basic template
|
|
20
|
+
# template = PromptTemplate.new(
|
|
21
|
+
# template: "Translate '{text}' to {language}",
|
|
22
|
+
# input_variables: [:text, :language]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Few-shot template
|
|
26
|
+
# template = PromptTemplate.new(
|
|
27
|
+
# prefix: "You are a translator. Here are some examples:",
|
|
28
|
+
# suffix: "Now translate: {input}",
|
|
29
|
+
# examples: [
|
|
30
|
+
# { input: "Hello", output: "Bonjour" },
|
|
31
|
+
# { input: "Goodbye", output: "Au revoir" }
|
|
32
|
+
# ],
|
|
33
|
+
# example_template: "Input: {input}\nOutput: {output}",
|
|
34
|
+
# input_variables: [:input]
|
|
35
|
+
# )
|
|
36
|
+
def initialize(template: nil, input_variables: [], prefix: nil, suffix: nil,
|
|
37
|
+
examples: nil, example_template: nil, partial_variables: {})
|
|
38
|
+
@template = template
|
|
39
|
+
@input_variables = Array(input_variables).map(&:to_sym)
|
|
40
|
+
@prefix = prefix
|
|
41
|
+
@suffix = suffix
|
|
42
|
+
@examples = examples
|
|
43
|
+
@example_template = build_example_template(example_template)
|
|
44
|
+
@partial_variables = partial_variables.transform_keys(&:to_sym)
|
|
45
|
+
|
|
46
|
+
validate_configuration!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Format the template with provided variables
|
|
50
|
+
#
|
|
51
|
+
# @param variables [Hash] Variable values to interpolate
|
|
52
|
+
# @return [String] Formatted prompt text
|
|
53
|
+
# @raise [ArgumentError] If required variables are missing
|
|
54
|
+
def format(variables = {})
|
|
55
|
+
variables = @partial_variables.merge(variables.transform_keys(&:to_sym))
|
|
56
|
+
validate_variables!(variables)
|
|
57
|
+
|
|
58
|
+
if few_shot_template?
|
|
59
|
+
format_few_shot(variables)
|
|
60
|
+
else
|
|
61
|
+
format_simple(variables)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Format as chat messages for OpenRouter API
|
|
66
|
+
#
|
|
67
|
+
# @param variables [Hash] Variable values to interpolate
|
|
68
|
+
# @param role [String] Role for the message (user, system, assistant)
|
|
69
|
+
# @return [Array<Hash>] Messages array for OpenRouter API
|
|
70
|
+
def to_messages(variables = {})
|
|
71
|
+
formatted = format(variables)
|
|
72
|
+
|
|
73
|
+
# Split by role markers if present (e.g., "System: ... User: ...")
|
|
74
|
+
if formatted.include?("System:") || formatted.include?("Assistant:") || formatted.include?("User:")
|
|
75
|
+
parse_chat_format(formatted)
|
|
76
|
+
else
|
|
77
|
+
# Default to single user message
|
|
78
|
+
[{ role: "user", content: formatted }]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create a partial template with some variables pre-filled
|
|
83
|
+
#
|
|
84
|
+
# @param partial_variables [Hash] Variables to pre-fill
|
|
85
|
+
# @return [PromptTemplate] New template with partial variables
|
|
86
|
+
def partial(partial_variables = {})
|
|
87
|
+
self.class.new(
|
|
88
|
+
template: @template,
|
|
89
|
+
input_variables: @input_variables - partial_variables.keys.map(&:to_sym),
|
|
90
|
+
prefix: @prefix,
|
|
91
|
+
suffix: @suffix,
|
|
92
|
+
examples: @examples,
|
|
93
|
+
example_template: @example_template,
|
|
94
|
+
partial_variables: @partial_variables.merge(partial_variables.transform_keys(&:to_sym))
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Check if this is a few-shot template
|
|
99
|
+
#
|
|
100
|
+
# @return [Boolean]
|
|
101
|
+
def few_shot_template?
|
|
102
|
+
!@examples.nil? && !@examples.empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Class method for convenient DSL-style creation
|
|
106
|
+
#
|
|
107
|
+
# @example DSL usage
|
|
108
|
+
# template = PromptTemplate.build do
|
|
109
|
+
# template "Translate '{text}' to {language}"
|
|
110
|
+
# variables :text, :language
|
|
111
|
+
# end
|
|
112
|
+
def self.build(&block)
|
|
113
|
+
builder = Builder.new
|
|
114
|
+
builder.instance_eval(&block)
|
|
115
|
+
builder.build
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_configuration!
|
|
121
|
+
raise ArgumentError, "Either template or suffix must be provided" if @template.nil? && @suffix.nil?
|
|
122
|
+
|
|
123
|
+
return unless few_shot_template? && @example_template.nil?
|
|
124
|
+
|
|
125
|
+
raise ArgumentError, "example_template is required when examples are provided"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_variables!(variables)
|
|
129
|
+
missing = @input_variables - variables.keys
|
|
130
|
+
return if missing.empty?
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "Missing required variables: #{missing.join(", ")}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_simple(variables)
|
|
136
|
+
interpolate(@template, variables)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def format_few_shot(variables)
|
|
140
|
+
parts = []
|
|
141
|
+
parts << interpolate(@prefix, variables) if @prefix
|
|
142
|
+
|
|
143
|
+
if @examples && @example_template
|
|
144
|
+
formatted_examples = @examples.map do |example|
|
|
145
|
+
# Use only the example data for formatting, not user-provided variables
|
|
146
|
+
@example_template.format(example)
|
|
147
|
+
end
|
|
148
|
+
parts.concat(formatted_examples)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parts << interpolate(@suffix, variables) if @suffix
|
|
152
|
+
parts.join("\n\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def interpolate(text, variables)
|
|
156
|
+
return "" if text.nil?
|
|
157
|
+
|
|
158
|
+
result = text.dup
|
|
159
|
+
variables.each do |key, value|
|
|
160
|
+
# Support both {var} and {var:format} syntax
|
|
161
|
+
result.gsub!(/\{#{Regexp.escape(key.to_s)}(?::[^}]+)?\}/, value.to_s)
|
|
162
|
+
end
|
|
163
|
+
result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_example_template(template)
|
|
167
|
+
case template
|
|
168
|
+
when PromptTemplate
|
|
169
|
+
template
|
|
170
|
+
when String
|
|
171
|
+
PromptTemplate.new(
|
|
172
|
+
template: template,
|
|
173
|
+
input_variables: extract_variables(template)
|
|
174
|
+
)
|
|
175
|
+
when nil
|
|
176
|
+
nil
|
|
177
|
+
else
|
|
178
|
+
raise ArgumentError, "example_template must be a String or PromptTemplate"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def extract_variables(text)
|
|
183
|
+
return [] if text.nil?
|
|
184
|
+
|
|
185
|
+
# Extract {variable} or {variable:format} patterns
|
|
186
|
+
text.scan(/\{(\w+)(?::[^}]+)?\}/).flatten.map(&:to_sym).uniq
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_chat_format(text)
|
|
190
|
+
messages = []
|
|
191
|
+
current_role = "user"
|
|
192
|
+
current_content = []
|
|
193
|
+
|
|
194
|
+
text.lines.each do |line|
|
|
195
|
+
if line.start_with?("System:")
|
|
196
|
+
unless current_content.empty?
|
|
197
|
+
messages << { role: current_role, content: current_content.join.strip }
|
|
198
|
+
current_content = []
|
|
199
|
+
end
|
|
200
|
+
current_role = "system"
|
|
201
|
+
current_content << line.sub("System:", "").strip
|
|
202
|
+
elsif line.start_with?("Assistant:")
|
|
203
|
+
unless current_content.empty?
|
|
204
|
+
messages << { role: current_role, content: current_content.join.strip }
|
|
205
|
+
current_content = []
|
|
206
|
+
end
|
|
207
|
+
current_role = "assistant"
|
|
208
|
+
current_content << line.sub("Assistant:", "").strip
|
|
209
|
+
elsif line.start_with?("User:")
|
|
210
|
+
unless current_content.empty?
|
|
211
|
+
messages << { role: current_role, content: current_content.join.strip }
|
|
212
|
+
current_content = []
|
|
213
|
+
end
|
|
214
|
+
current_role = "user"
|
|
215
|
+
current_content << line.sub("User:", "").strip
|
|
216
|
+
else
|
|
217
|
+
current_content << "\n" unless current_content.empty?
|
|
218
|
+
current_content << line
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
messages << { role: current_role, content: current_content.join.strip } unless current_content.empty?
|
|
223
|
+
|
|
224
|
+
messages
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Builder class for DSL-style template creation
|
|
228
|
+
class Builder
|
|
229
|
+
def initialize
|
|
230
|
+
@config = {}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def template(text)
|
|
234
|
+
@config[:template] = text
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def variables(*vars)
|
|
238
|
+
@config[:input_variables] = vars
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def prefix(text)
|
|
242
|
+
@config[:prefix] = text
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def suffix(text)
|
|
246
|
+
@config[:suffix] = text
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def examples(examples_array)
|
|
250
|
+
@config[:examples] = examples_array
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def example_template(template)
|
|
254
|
+
@config[:example_template] = template
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def partial_variables(vars)
|
|
258
|
+
@config[:partial_variables] = vars
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def build
|
|
262
|
+
PromptTemplate.new(**@config)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Convenient factory methods
|
|
268
|
+
module Prompt
|
|
269
|
+
# Create a simple prompt template
|
|
270
|
+
def self.template(template, variables: [])
|
|
271
|
+
PromptTemplate.new(template: template, input_variables: variables)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Create a few-shot prompt template
|
|
275
|
+
def self.few_shot(prefix:, suffix:, examples:, example_template:, variables:)
|
|
276
|
+
PromptTemplate.new(
|
|
277
|
+
prefix: prefix,
|
|
278
|
+
suffix: suffix,
|
|
279
|
+
examples: examples,
|
|
280
|
+
example_template: example_template,
|
|
281
|
+
input_variables: variables
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Create a chat-style template
|
|
286
|
+
def self.chat(&block)
|
|
287
|
+
PromptTemplate.build(&block)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
|
|
6
|
+
module OpenRouter
|
|
7
|
+
class StructuredOutputError < Error; end
|
|
8
|
+
|
|
9
|
+
class Response
|
|
10
|
+
attr_reader :raw_response, :response_format, :forced_extraction
|
|
11
|
+
attr_accessor :client
|
|
12
|
+
|
|
13
|
+
def initialize(raw_response, response_format: nil, forced_extraction: false)
|
|
14
|
+
@raw_response = raw_response.is_a?(Hash) ? raw_response.with_indifferent_access : {}
|
|
15
|
+
@response_format = response_format
|
|
16
|
+
@forced_extraction = forced_extraction
|
|
17
|
+
@client = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Delegate common hash methods to raw_response for backward compatibility
|
|
21
|
+
def [](key)
|
|
22
|
+
@raw_response[key]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dig(*keys)
|
|
26
|
+
@raw_response.dig(*keys)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch(key, default = nil)
|
|
30
|
+
@raw_response.fetch(key, default)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def key?(key)
|
|
34
|
+
@raw_response.key?(key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def keys
|
|
38
|
+
@raw_response.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def has_key?(key)
|
|
42
|
+
@raw_response.key?(key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
@raw_response.to_h
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_json(*args)
|
|
50
|
+
@raw_response.to_json(*args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Tool calling methods
|
|
54
|
+
def tool_calls
|
|
55
|
+
@tool_calls ||= parse_tool_calls
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def has_tool_calls?
|
|
59
|
+
!tool_calls.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convert response to message format for conversation continuation
|
|
63
|
+
def to_message
|
|
64
|
+
if has_tool_calls?
|
|
65
|
+
{
|
|
66
|
+
role: "assistant",
|
|
67
|
+
content: content,
|
|
68
|
+
tool_calls: raw_tool_calls
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
{
|
|
72
|
+
role: "assistant",
|
|
73
|
+
content: content
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Structured output methods
|
|
79
|
+
def structured_output(mode: nil, auto_heal: nil)
|
|
80
|
+
# Use global default mode if not specified
|
|
81
|
+
if mode.nil?
|
|
82
|
+
mode = if @client&.configuration.respond_to?(:default_structured_output_mode)
|
|
83
|
+
@client.configuration.default_structured_output_mode || :strict
|
|
84
|
+
else
|
|
85
|
+
:strict
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
# Validate mode parameter
|
|
89
|
+
raise ArgumentError, "Invalid mode: #{mode}. Must be :strict or :gentle." unless %i[strict gentle].include?(mode)
|
|
90
|
+
|
|
91
|
+
return nil unless structured_output_expected? && has_content?
|
|
92
|
+
|
|
93
|
+
case mode
|
|
94
|
+
when :strict
|
|
95
|
+
# The existing logic for strict parsing and healing
|
|
96
|
+
should_heal = if auto_heal.nil?
|
|
97
|
+
@client&.configuration&.auto_heal_responses
|
|
98
|
+
else
|
|
99
|
+
auto_heal
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
result = parse_and_heal_structured_output(auto_heal: should_heal)
|
|
103
|
+
|
|
104
|
+
# Only validate after parsing if healing is disabled (healing handles its own validation)
|
|
105
|
+
if result && !should_heal
|
|
106
|
+
schema_obj = extract_schema_from_response_format
|
|
107
|
+
if schema_obj && !schema_obj.validate(result)
|
|
108
|
+
validation_errors = schema_obj.validation_errors(result)
|
|
109
|
+
raise StructuredOutputError, "Schema validation failed: #{validation_errors.join(", ")}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@structured_output ||= result
|
|
114
|
+
when :gentle
|
|
115
|
+
# New gentle mode: best-effort parsing, no healing, no validation
|
|
116
|
+
content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
|
|
117
|
+
return nil if content_to_parse.nil?
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
JSON.parse(content_to_parse)
|
|
121
|
+
rescue JSON::ParserError
|
|
122
|
+
nil # Return nil on failure instead of raising an error
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def valid_structured_output?
|
|
128
|
+
return true unless structured_output_expected?
|
|
129
|
+
|
|
130
|
+
schema_obj = extract_schema_from_response_format
|
|
131
|
+
return true unless schema_obj
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
parsed_output = structured_output
|
|
135
|
+
return false unless parsed_output
|
|
136
|
+
|
|
137
|
+
schema_obj.validate(parsed_output)
|
|
138
|
+
rescue StructuredOutputError
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validation_errors
|
|
144
|
+
return [] unless structured_output_expected?
|
|
145
|
+
|
|
146
|
+
schema_obj = extract_schema_from_response_format
|
|
147
|
+
return [] unless schema_obj
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
parsed_output = structured_output
|
|
151
|
+
return [] unless parsed_output
|
|
152
|
+
|
|
153
|
+
schema_obj.validation_errors(parsed_output)
|
|
154
|
+
rescue StructuredOutputError
|
|
155
|
+
["Failed to parse structured output"]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Content accessors
|
|
160
|
+
def content
|
|
161
|
+
choices.first&.dig("message", "content")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def choices
|
|
165
|
+
@raw_response["choices"] || []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def usage
|
|
169
|
+
@raw_response["usage"]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def id
|
|
173
|
+
@raw_response["id"]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def model
|
|
177
|
+
@raw_response["model"]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def created
|
|
181
|
+
@raw_response["created"]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def object
|
|
185
|
+
@raw_response["object"]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Provider information
|
|
189
|
+
def provider
|
|
190
|
+
@raw_response["provider"]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# System fingerprint (model version identifier)
|
|
194
|
+
def system_fingerprint
|
|
195
|
+
@raw_response["system_fingerprint"]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Native finish reason from the provider
|
|
199
|
+
def native_finish_reason
|
|
200
|
+
choices.first&.dig("native_finish_reason")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Finish reason (standard OpenRouter format)
|
|
204
|
+
def finish_reason
|
|
205
|
+
choices.first&.dig("finish_reason")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Cached tokens (tokens served from cache)
|
|
209
|
+
def cached_tokens
|
|
210
|
+
usage&.dig("prompt_tokens_details", "cached_tokens") || 0
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Total prompt tokens
|
|
214
|
+
def prompt_tokens
|
|
215
|
+
usage&.dig("prompt_tokens") || 0
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Total completion tokens
|
|
219
|
+
def completion_tokens
|
|
220
|
+
usage&.dig("completion_tokens") || 0
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Total tokens (prompt + completion)
|
|
224
|
+
def total_tokens
|
|
225
|
+
usage&.dig("total_tokens") || 0
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Get estimated cost for this response
|
|
229
|
+
# Note: This requires an additional API call to /generation endpoint
|
|
230
|
+
def cost_estimate
|
|
231
|
+
return nil unless id && client
|
|
232
|
+
|
|
233
|
+
@cost_estimate ||= client.query_generation_stats(id)&.dig("cost")
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Convenience method to check if response has content
|
|
239
|
+
def has_content?
|
|
240
|
+
!content.nil? && !content.empty?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Convenience method to check if response indicates an error
|
|
244
|
+
def error?
|
|
245
|
+
@raw_response.key?("error")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def error_message
|
|
249
|
+
@raw_response.dig("error", "message")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def parse_tool_calls
|
|
255
|
+
tool_calls_data = choices.first&.dig("message", "tool_calls")
|
|
256
|
+
return [] unless tool_calls_data.is_a?(Array)
|
|
257
|
+
|
|
258
|
+
tool_calls_data.map { |tc| ToolCall.new(tc) }
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
raise ToolCallError, "Failed to parse tool calls: #{e.message}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def raw_tool_calls
|
|
264
|
+
choices.first&.dig("message", "tool_calls") || []
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_and_heal_structured_output(auto_heal: false)
|
|
268
|
+
return nil unless structured_output_expected?
|
|
269
|
+
return nil unless has_content?
|
|
270
|
+
|
|
271
|
+
content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
|
|
272
|
+
|
|
273
|
+
if auto_heal && @client
|
|
274
|
+
# For forced extraction: always send full content to provide context for healing
|
|
275
|
+
# For normal responses: send the content as-is
|
|
276
|
+
healing_content = if @forced_extraction
|
|
277
|
+
content # Always send full response for better healing context
|
|
278
|
+
else
|
|
279
|
+
content_to_parse || content
|
|
280
|
+
end
|
|
281
|
+
heal_structured_response(healing_content, extract_schema_from_response_format)
|
|
282
|
+
else
|
|
283
|
+
return nil if content_to_parse.nil? # No JSON found in forced extraction
|
|
284
|
+
|
|
285
|
+
begin
|
|
286
|
+
JSON.parse(content_to_parse)
|
|
287
|
+
rescue JSON::ParserError => e
|
|
288
|
+
# For forced extraction, be more lenient and return nil on parse failures
|
|
289
|
+
# For regular structured outputs, return nil if content looks like it contains markdown
|
|
290
|
+
# (indicates it's not actually structured JSON output)
|
|
291
|
+
if @forced_extraction
|
|
292
|
+
nil
|
|
293
|
+
elsif content_to_parse&.include?("```")
|
|
294
|
+
# Content contains markdown blocks - this is not structured output
|
|
295
|
+
nil
|
|
296
|
+
else
|
|
297
|
+
raise StructuredOutputError, "Failed to parse structured output: #{e.message}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Extract JSON from text content (for forced structured output)
|
|
304
|
+
def extract_json_from_text(text)
|
|
305
|
+
return nil if text.nil? || text.empty?
|
|
306
|
+
|
|
307
|
+
# First try to find JSON in code blocks
|
|
308
|
+
if text.include?("```")
|
|
309
|
+
# Look for ```json or ``` blocks
|
|
310
|
+
json_match = text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)
|
|
311
|
+
if json_match
|
|
312
|
+
candidate = json_match[1].strip
|
|
313
|
+
return candidate unless candidate.empty?
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Try to parse the entire text as JSON
|
|
318
|
+
begin
|
|
319
|
+
JSON.parse(text)
|
|
320
|
+
return text
|
|
321
|
+
rescue JSON::ParserError
|
|
322
|
+
# Look for JSON-like content (starts with { or [)
|
|
323
|
+
json_match = text.match(/(\{.*\}|\[.*\])/m)
|
|
324
|
+
return json_match[1] if json_match
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# No JSON found
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def structured_output_expected?
|
|
332
|
+
return false unless @response_format
|
|
333
|
+
|
|
334
|
+
if @response_format.is_a?(Schema)
|
|
335
|
+
true
|
|
336
|
+
elsif @response_format.is_a?(Hash) && @response_format[:type] == "json_schema"
|
|
337
|
+
true
|
|
338
|
+
else
|
|
339
|
+
false
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def extract_schema_from_response_format
|
|
344
|
+
case @response_format
|
|
345
|
+
when Schema
|
|
346
|
+
@response_format
|
|
347
|
+
when Hash
|
|
348
|
+
schema_def = @response_format[:json_schema]
|
|
349
|
+
if schema_def.is_a?(Schema)
|
|
350
|
+
schema_def
|
|
351
|
+
elsif schema_def.is_a?(Hash) && schema_def[:schema]
|
|
352
|
+
# Create a temporary schema object for validation
|
|
353
|
+
Schema.new(
|
|
354
|
+
schema_def[:name] || "response",
|
|
355
|
+
schema_def[:schema],
|
|
356
|
+
strict: schema_def.key?(:strict) ? schema_def[:strict] : true
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Backward compatibility method that delegates to JsonHealer
|
|
363
|
+
def heal_structured_response(content, schema)
|
|
364
|
+
return JSON.parse(content) unless schema
|
|
365
|
+
|
|
366
|
+
healer = JsonHealer.new(@client)
|
|
367
|
+
context = @forced_extraction ? :forced_extraction : :generic
|
|
368
|
+
healer.heal(content, schema, context: context)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|