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,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