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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module OpenRouter
6
+ class ModelSelectionError < Error; end
7
+
8
+ # ModelSelector provides a fluent DSL interface for selecting the best AI model
9
+ # based on specific requirements. It wraps the ModelRegistry functionality
10
+ # with an intuitive, chainable API.
11
+ #
12
+ # @example Basic usage
13
+ # selector = OpenRouter::ModelSelector.new
14
+ # model = selector.optimize_for(:cost)
15
+ # .require(:function_calling, :vision)
16
+ # .within_budget(max_cost: 0.01)
17
+ # .choose
18
+ #
19
+ # @example With provider preferences
20
+ # model = OpenRouter::ModelSelector.new
21
+ # .prefer_providers("anthropic", "openai")
22
+ # .require(:function_calling)
23
+ # .choose
24
+ #
25
+ # @example With fallback selection
26
+ # models = OpenRouter::ModelSelector.new
27
+ # .optimize_for(:performance)
28
+ # .choose_with_fallbacks(limit: 3)
29
+ class ModelSelector
30
+ # Available optimization strategies
31
+ STRATEGIES = {
32
+ cost: { sort_by: :cost, pick_newer: false },
33
+ performance: { sort_by: :performance, pick_newer: false },
34
+ latest: { sort_by: :date, pick_newer: true },
35
+ context: { sort_by: :context_length, pick_newer: false }
36
+ }.freeze
37
+
38
+ def initialize(requirements: {}, strategy: :cost, provider_preferences: {}, fallback_options: {})
39
+ @requirements = requirements.dup
40
+ @strategy = strategy
41
+ @provider_preferences = provider_preferences.dup
42
+ @fallback_options = fallback_options.dup
43
+ end
44
+
45
+ # Set the optimization strategy for model selection
46
+ #
47
+ # @param strategy [Symbol] The optimization strategy (:cost, :performance, :latest, :context)
48
+ # @return [ModelSelector] Returns self for method chaining
49
+ # @raise [ArgumentError] If strategy is not supported
50
+ #
51
+ # @example
52
+ # selector.optimize_for(:cost) # Choose cheapest model
53
+ # selector.optimize_for(:performance) # Choose highest performance tier
54
+ # selector.optimize_for(:latest) # Choose newest model
55
+ # selector.optimize_for(:context) # Choose model with largest context window
56
+ def optimize_for(strategy)
57
+ unless STRATEGIES.key?(strategy)
58
+ raise ArgumentError,
59
+ "Unknown strategy: #{strategy}. Available: #{STRATEGIES.keys.join(", ")}"
60
+ end
61
+
62
+ new_requirements = @requirements.dup
63
+
64
+ # Apply strategy-specific requirements
65
+ case strategy
66
+ when :performance
67
+ new_requirements[:performance_tier] = :premium
68
+ when :latest
69
+ new_requirements[:pick_newer] = true
70
+ end
71
+
72
+ self.class.new(
73
+ requirements: new_requirements,
74
+ strategy:,
75
+ provider_preferences: @provider_preferences,
76
+ fallback_options: @fallback_options
77
+ )
78
+ end
79
+
80
+ # Require specific capabilities from the selected model
81
+ #
82
+ # @param capabilities [Array<Symbol>] Required capabilities
83
+ # @return [ModelSelector] Returns self for method chaining
84
+ #
85
+ # @example
86
+ # selector.require(:function_calling)
87
+ # selector.require(:function_calling, :vision, :structured_outputs)
88
+ def require(*capabilities)
89
+ new_requirements = @requirements.dup
90
+ new_requirements[:capabilities] = Array(new_requirements[:capabilities]) + capabilities
91
+ new_requirements[:capabilities].uniq!
92
+
93
+ self.class.new(
94
+ requirements: new_requirements,
95
+ strategy: @strategy,
96
+ provider_preferences: @provider_preferences,
97
+ fallback_options: @fallback_options
98
+ )
99
+ end
100
+
101
+ # Set budget constraints for model selection
102
+ #
103
+ # @param max_cost [Float] Maximum cost per 1k input tokens
104
+ # @param max_output_cost [Float] Maximum cost per 1k output tokens (optional)
105
+ # @return [ModelSelector] Returns self for method chaining
106
+ #
107
+ # @example
108
+ # selector.within_budget(max_cost: 0.01)
109
+ # selector.within_budget(max_cost: 0.01, max_output_cost: 0.02)
110
+ def within_budget(max_cost: nil, max_output_cost: nil)
111
+ new_requirements = @requirements.dup
112
+ new_requirements[:max_input_cost] = max_cost if max_cost
113
+ new_requirements[:max_output_cost] = max_output_cost if max_output_cost
114
+
115
+ self.class.new(
116
+ requirements: new_requirements,
117
+ strategy: @strategy,
118
+ provider_preferences: @provider_preferences,
119
+ fallback_options: @fallback_options
120
+ )
121
+ end
122
+
123
+ # Set minimum context length requirement
124
+ #
125
+ # @param tokens [Integer] Minimum context length in tokens
126
+ # @return [ModelSelector] Returns self for method chaining
127
+ #
128
+ # @example
129
+ # selector.min_context(100_000) # Require at least 100k context
130
+ def min_context(tokens)
131
+ new_requirements = @requirements.dup
132
+ new_requirements[:min_context_length] = tokens
133
+
134
+ self.class.new(
135
+ requirements: new_requirements,
136
+ strategy: @strategy,
137
+ provider_preferences: @provider_preferences,
138
+ fallback_options: @fallback_options
139
+ )
140
+ end
141
+
142
+ # Require models released after a specific date
143
+ #
144
+ # @param date [Date, Time, Integer] Cutoff date (Date/Time object or Unix timestamp)
145
+ # @return [ModelSelector] Returns self for method chaining
146
+ #
147
+ # @example
148
+ # selector.newer_than(Date.new(2024, 1, 1))
149
+ # selector.newer_than(Time.now - 30.days)
150
+ def newer_than(date)
151
+ new_requirements = @requirements.dup
152
+ new_requirements[:released_after_date] = date
153
+
154
+ self.class.new(
155
+ requirements: new_requirements,
156
+ strategy: @strategy,
157
+ provider_preferences: @provider_preferences,
158
+ fallback_options: @fallback_options
159
+ )
160
+ end
161
+
162
+ # Set provider preferences (soft preference - won't exclude other providers)
163
+ #
164
+ # @param providers [Array<String>] Preferred provider names in order of preference
165
+ # @return [ModelSelector] Returns self for method chaining
166
+ #
167
+ # @example
168
+ # selector.prefer_providers("anthropic", "openai")
169
+ def prefer_providers(*providers)
170
+ new_provider_preferences = @provider_preferences.dup
171
+ new_provider_preferences[:preferred] = providers.flatten
172
+
173
+ self.class.new(
174
+ requirements: @requirements,
175
+ strategy: @strategy,
176
+ provider_preferences: new_provider_preferences,
177
+ fallback_options: @fallback_options
178
+ )
179
+ end
180
+
181
+ # Require specific providers (hard filter - only these providers)
182
+ #
183
+ # @param providers [Array<String>] Required provider names
184
+ # @return [ModelSelector] Returns self for method chaining
185
+ #
186
+ # @example
187
+ # selector.require_providers("anthropic")
188
+ def require_providers(*providers)
189
+ new_provider_preferences = @provider_preferences.dup
190
+ new_provider_preferences[:required] = providers.flatten
191
+
192
+ self.class.new(
193
+ requirements: @requirements,
194
+ strategy: @strategy,
195
+ provider_preferences: new_provider_preferences,
196
+ fallback_options: @fallback_options
197
+ )
198
+ end
199
+
200
+ # Avoid specific providers (blacklist)
201
+ #
202
+ # @param providers [Array<String>] Provider names to avoid
203
+ # @return [ModelSelector] Returns self for method chaining
204
+ #
205
+ # @example
206
+ # selector.avoid_providers("google")
207
+ def avoid_providers(*providers)
208
+ new_provider_preferences = @provider_preferences.dup
209
+ new_provider_preferences[:avoided] = providers.flatten
210
+
211
+ self.class.new(
212
+ requirements: @requirements,
213
+ strategy: @strategy,
214
+ provider_preferences: new_provider_preferences,
215
+ fallback_options: @fallback_options
216
+ )
217
+ end
218
+
219
+ # Avoid models matching specific patterns
220
+ #
221
+ # @param patterns [Array<String>] Glob patterns to avoid
222
+ # @return [ModelSelector] Returns self for method chaining
223
+ #
224
+ # @example
225
+ # selector.avoid_patterns("*-free", "*-preview")
226
+ def avoid_patterns(*patterns)
227
+ new_provider_preferences = @provider_preferences.dup
228
+ new_provider_preferences[:avoided_patterns] = patterns.flatten
229
+
230
+ self.class.new(
231
+ requirements: @requirements,
232
+ strategy: @strategy,
233
+ provider_preferences: new_provider_preferences,
234
+ fallback_options: @fallback_options
235
+ )
236
+ end
237
+
238
+ # Configure fallback behavior
239
+ #
240
+ # @param max_fallbacks [Integer] Maximum number of fallback models to include
241
+ # @param strategy [Symbol] Fallback strategy (:similar, :cheaper, :different_provider)
242
+ # @return [ModelSelector] Returns self for method chaining
243
+ #
244
+ # @example
245
+ # selector.with_fallbacks(max: 3, strategy: :similar)
246
+ def with_fallbacks(max: 3, strategy: :similar)
247
+ new_fallback_options = { max_fallbacks: max, strategy: }
248
+
249
+ self.class.new(
250
+ requirements: @requirements,
251
+ strategy: @strategy,
252
+ provider_preferences: @provider_preferences,
253
+ fallback_options: new_fallback_options
254
+ )
255
+ end
256
+
257
+ # Select the best model based on configured requirements
258
+ #
259
+ # @param return_specs [Boolean] Whether to return model specs along with model ID
260
+ # @return [String, Array] Model ID or [model_id, specs] tuple if return_specs is true
261
+ # @return [nil] If no models match requirements
262
+ #
263
+ # @example
264
+ # model = selector.choose
265
+ # model, specs = selector.choose(return_specs: true)
266
+ def choose(return_specs: false)
267
+ # Get all models that meet basic requirements
268
+ candidates = filter_by_providers(ModelRegistry.models_meeting_requirements(@requirements))
269
+
270
+ return nil if candidates.empty?
271
+
272
+ # Apply strategy-specific sorting
273
+ best_match = apply_strategy_sorting(candidates)
274
+
275
+ return nil unless best_match
276
+
277
+ return_specs ? best_match : best_match.first
278
+ end
279
+
280
+ # Select the best model with fallback options
281
+ #
282
+ # @param limit [Integer] Maximum number of models to return (including primary choice)
283
+ # @return [Array<String>] Array of model IDs in order of preference
284
+ # @return [Array] Empty array if no models match requirements
285
+ #
286
+ # @example
287
+ # models = selector.choose_with_fallbacks(limit: 3)
288
+ # # => ["gpt-4", "claude-3-opus", "gpt-3.5-turbo"]
289
+ def choose_with_fallbacks(limit: 3)
290
+ candidates = filter_by_providers(ModelRegistry.models_meeting_requirements(@requirements))
291
+
292
+ return [] if candidates.empty?
293
+
294
+ # Apply strategy-specific sorting to get ordered list
295
+ sorted_candidates = apply_strategy_sorting_all(candidates)
296
+
297
+ # Return up to `limit` models
298
+ sorted_candidates.first(limit).map(&:first)
299
+ end
300
+
301
+ # Choose with graceful degradation if no models meet all requirements
302
+ #
303
+ # @return [String, nil] Model ID or nil if no models available at all
304
+ #
305
+ # @example
306
+ # model = selector.choose_with_fallback
307
+ def choose_with_fallback
308
+ # Try with all requirements first
309
+ result = choose
310
+ return result if result
311
+
312
+ # Try dropping least important requirements progressively
313
+ fallback_requirements = @requirements.dup
314
+
315
+ # Drop requirements in order of importance (least to most important)
316
+ %i[
317
+ released_after_date
318
+ performance_tier
319
+ max_output_cost
320
+ min_context_length
321
+ max_input_cost
322
+ ].each do |requirement|
323
+ next unless fallback_requirements.key?(requirement)
324
+
325
+ fallback_requirements.delete(requirement)
326
+ candidates = filter_by_providers(ModelRegistry.models_meeting_requirements(fallback_requirements))
327
+
328
+ unless candidates.empty?
329
+ result = apply_strategy_sorting(candidates)
330
+ return result&.first
331
+ end
332
+ end
333
+
334
+ # Last resort: just pick any model that meets capability requirements
335
+ if fallback_requirements[:capabilities]
336
+ basic_requirements = { capabilities: fallback_requirements[:capabilities] }
337
+ candidates = filter_by_providers(ModelRegistry.models_meeting_requirements(basic_requirements))
338
+ result = apply_strategy_sorting(candidates) unless candidates.empty?
339
+ return result&.first if result
340
+ end
341
+
342
+ # Final fallback: cheapest available model
343
+ all_candidates = filter_by_providers(ModelRegistry.all_models)
344
+ return nil if all_candidates.empty?
345
+
346
+ all_candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }&.first
347
+ end
348
+
349
+ # Get detailed information about the current selection criteria
350
+ #
351
+ # @return [Hash] Hash containing requirements, strategy, and provider preferences
352
+ def selection_criteria
353
+ {
354
+ requirements: deep_dup(@requirements),
355
+ strategy: @strategy,
356
+ provider_preferences: deep_dup(@provider_preferences),
357
+ fallback_options: deep_dup(@fallback_options)
358
+ }
359
+ end
360
+
361
+ # Estimate cost for a given model with expected token usage
362
+ #
363
+ # @param model [String] Model ID
364
+ # @param input_tokens [Integer] Expected input tokens
365
+ # @param output_tokens [Integer] Expected output tokens
366
+ # @return [Float] Estimated cost in dollars
367
+ def estimate_cost(model, input_tokens: 1000, output_tokens: 1000)
368
+ ModelRegistry.calculate_estimated_cost(model, input_tokens:, output_tokens:)
369
+ end
370
+
371
+ private
372
+
373
+ # Deep duplicate a hash or array to avoid shared references
374
+ def deep_dup(obj)
375
+ case obj
376
+ when Hash
377
+ obj.transform_values { |v| deep_dup(v) }
378
+ when Array
379
+ obj.map { |item| deep_dup(item) }
380
+ else
381
+ obj
382
+ end
383
+ end
384
+
385
+ # Filter candidates by provider preferences
386
+ def filter_by_providers(candidates)
387
+ return candidates if @provider_preferences.empty?
388
+
389
+ filtered = candidates.dup
390
+
391
+ # Apply required providers filter (hard requirement)
392
+ if @provider_preferences[:required]
393
+ required_providers = @provider_preferences[:required]
394
+ filtered = filtered.select do |model_id, _|
395
+ provider = extract_provider_from_model_id(model_id)
396
+ required_providers.include?(provider)
397
+ end
398
+ end
399
+
400
+ # Apply avoided providers filter
401
+ if @provider_preferences[:avoided]
402
+ avoided_providers = @provider_preferences[:avoided]
403
+ filtered = filtered.reject do |model_id, _|
404
+ provider = extract_provider_from_model_id(model_id)
405
+ avoided_providers.include?(provider)
406
+ end
407
+ end
408
+
409
+ # Apply avoided patterns filter
410
+ if @provider_preferences[:avoided_patterns]
411
+ patterns = @provider_preferences[:avoided_patterns]
412
+ filtered = filtered.reject do |model_id, _|
413
+ patterns.any? { |pattern| File.fnmatch(pattern, model_id) }
414
+ end
415
+ end
416
+
417
+ filtered
418
+ end
419
+
420
+ # Extract provider name from model ID (e.g., "anthropic/claude-3" -> "anthropic")
421
+ def extract_provider_from_model_id(model_id)
422
+ model_id.split("/").first
423
+ end
424
+
425
+ # Apply strategy-specific sorting and return best match
426
+ def apply_strategy_sorting(candidates)
427
+ case @strategy
428
+ when :cost
429
+ candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
430
+ when :performance
431
+ # Prefer premium tier, then by cost within tier
432
+ candidates.min_by do |_, specs|
433
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
434
+ end
435
+ when :latest
436
+ candidates.max_by { |_, specs| (specs[:created_at] || 0).to_i }
437
+ when :context
438
+ candidates.max_by { |_, specs| (specs[:context_length] || 0).to_i }
439
+ else
440
+ candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
441
+ end
442
+ end
443
+
444
+ # Apply strategy-specific sorting and return all sorted candidates
445
+ def apply_strategy_sorting_all(candidates)
446
+ case @strategy
447
+ when :cost
448
+ candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
449
+ when :performance
450
+ candidates.sort_by do |_, specs|
451
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
452
+ end
453
+ when :latest
454
+ candidates.sort_by { |_, specs| -(specs[:created_at] || 0).to_i }
455
+ when :context
456
+ candidates.sort_by { |_, specs| -(specs[:context_length] || 0).to_i }
457
+ else
458
+ candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
459
+ end
460
+ end
461
+ end
462
+ end