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,637 @@
1
+ # Model Selection
2
+
3
+ The OpenRouter gem includes sophisticated model selection capabilities that help you automatically choose the best AI model based on your specific requirements. The system combines intelligent model registry caching with a fluent DSL for expressing selection criteria.
4
+
5
+ ## Quick Start
6
+
7
+ ```ruby
8
+ # Find the cheapest model that supports function calling
9
+ model = OpenRouter::ModelSelector.new
10
+ .require(:function_calling)
11
+ .optimize_for(:cost)
12
+ .choose
13
+
14
+ # Use the selected model
15
+ response = client.complete(messages, model: model, tools: tools)
16
+ ```
17
+
18
+ ## ModelSelector API
19
+
20
+ The `ModelSelector` class provides a fluent interface for building complex model selection criteria.
21
+
22
+ ### Basic Usage
23
+
24
+ ```ruby
25
+ selector = OpenRouter::ModelSelector.new
26
+
27
+ # Method chaining for requirements
28
+ model = selector.require(:function_calling, :vision)
29
+ .within_budget(max_cost: 0.01)
30
+ .prefer_providers("anthropic", "openai")
31
+ .optimize_for(:performance)
32
+ .choose
33
+
34
+ puts model # => "anthropic/claude-3.5-sonnet"
35
+ ```
36
+
37
+ ### Optimization Strategies
38
+
39
+ ```ruby
40
+ # Optimize for cost - choose cheapest input token cost
41
+ model = selector.optimize_for(:cost).choose
42
+
43
+ # Optimize for performance - prefer premium tier, then by cost
44
+ model = selector.optimize_for(:performance).choose
45
+
46
+ # Optimize for latest - prefer most recent model
47
+ model = selector.optimize_for(:latest).choose
48
+
49
+ # Optimize for context - prefer largest context window
50
+ model = selector.optimize_for(:context).choose
51
+ ```
52
+
53
+ **Note**: Sorting is nil-safe for `created_at` and `context_length`.
54
+
55
+ ### Capability Requirements
56
+
57
+ ```ruby
58
+ # Require specific capabilities
59
+ selector.require(:function_calling) # Single capability
60
+ selector.require(:function_calling, :vision) # Multiple capabilities
61
+ selector.require(:structured_outputs, :long_context) # Mix and match
62
+
63
+ # Available capabilities:
64
+ # :function_calling - Tool/function calling support
65
+ # :structured_outputs - JSON schema response formatting
66
+ # :vision - Image input processing
67
+ # :long_context - Large context windows (100k+ tokens)
68
+ # :chat - Basic chat completion (all models have this)
69
+ ```
70
+
71
+ ### Budget Constraints
72
+
73
+ ```ruby
74
+ # Set maximum costs per 1k tokens
75
+ selector.within_budget(max_cost: 0.01) # Input tokens only
76
+ selector.within_budget(max_cost: 0.01, max_output_cost: 0.02) # Both input and output
77
+
78
+ # Example: Find cheapest model under $0.005 per 1k input tokens
79
+ model = OpenRouter::ModelSelector.new
80
+ .within_budget(max_cost: 0.005)
81
+ .require(:function_calling)
82
+ .choose
83
+ ```
84
+
85
+ ### Context Length Requirements
86
+
87
+ ```ruby
88
+ # Require minimum context length
89
+ selector.min_context(50_000) # 50k tokens minimum
90
+ selector.min_context(200_000) # 200k tokens minimum
91
+
92
+ # Find model with large context for document processing
93
+ model = OpenRouter::ModelSelector.new
94
+ .min_context(100_000)
95
+ .optimize_for(:cost)
96
+ .choose
97
+ ```
98
+
99
+ ### Provider Preferences and Filtering
100
+
101
+ ```ruby
102
+ # Soft preference - records preference but doesn't currently affect ordering
103
+ selector.prefer_providers("anthropic", "openai")
104
+
105
+ # Hard requirement - only these providers
106
+ selector.require_providers("anthropic")
107
+
108
+ # Avoid specific providers
109
+ selector.avoid_providers("google", "meta")
110
+
111
+ # Avoid model patterns (glob syntax)
112
+ selector.avoid_patterns("*-free", "*-preview", "*-alpha")
113
+ ```
114
+
115
+ **Note**: `prefer_providers` is available to record preferences but does not currently affect ordering. Filtering is enforced via `require_providers`/`avoid_providers`/`avoid_patterns`.
116
+
117
+ ### Release Date Filtering
118
+
119
+ ```ruby
120
+ # Only models released after a specific date
121
+ selector.newer_than(Date.new(2024, 1, 1))
122
+ selector.newer_than(Time.now - 30.days)
123
+ selector.newer_than(1704067200) # Unix timestamp
124
+
125
+ # Find latest models from this year
126
+ model = OpenRouter::ModelSelector.new
127
+ .newer_than(Date.new(2024, 1, 1))
128
+ .optimize_for(:latest)
129
+ .choose
130
+ ```
131
+
132
+ ## Selection Methods
133
+
134
+ ### Single Model Selection
135
+
136
+ ```ruby
137
+ # Get just the model ID
138
+ model = selector.choose
139
+ # => "anthropic/claude-3.5-sonnet"
140
+
141
+ # Get model ID with detailed specs
142
+ model, specs = selector.choose(return_specs: true)
143
+ # => ["anthropic/claude-3.5-sonnet", { capabilities: [...], cost_per_1k_tokens: {...}, ... }]
144
+ ```
145
+
146
+ ### Multiple Models with Fallbacks
147
+
148
+ ```ruby
149
+ # Get multiple models in order of preference
150
+ models = selector.choose_with_fallbacks(limit: 3)
151
+ # => ["anthropic/claude-3.5-sonnet", "openai/gpt-4o", "anthropic/claude-3-haiku"]
152
+
153
+ # Use first model, fall back to others if needed
154
+ models.each do |model|
155
+ begin
156
+ response = client.complete(messages, model: model)
157
+ break
158
+ rescue OpenRouter::ServerError => e
159
+ puts "Model #{model} failed: #{e.message}"
160
+ next
161
+ end
162
+ end
163
+ ```
164
+
165
+ ### Graceful Degradation
166
+
167
+ ```ruby
168
+ # choose_with_fallback drops least important requirements progressively if no match
169
+ model = selector.require(:function_calling, :vision)
170
+ .within_budget(max_cost: 0.001) # Very strict budget
171
+ .choose_with_fallback
172
+ ```
173
+
174
+ **Drop order**:
175
+ 1. `released_after_date`
176
+ 2. `performance_tier`
177
+ 3. `max_output_cost`
178
+ 4. `min_context_length`
179
+ 5. `max_input_cost`
180
+ 6. Keep only capability requirements
181
+ 7. Otherwise choose the cheapest available model
182
+
183
+ ## ModelRegistry
184
+
185
+ The underlying model registry provides direct access to model data and capabilities.
186
+
187
+ ### Model Information
188
+
189
+ ```ruby
190
+ # Get all available models
191
+ all_models = OpenRouter::ModelRegistry.all_models
192
+ # => { "anthropic/claude-3.5-sonnet" => { capabilities: [...], cost_per_1k_tokens: {...}, ...}, ... }
193
+
194
+ # Get specific model information
195
+ model_info = OpenRouter::ModelRegistry.get_model_info("anthropic/claude-3.5-sonnet")
196
+ puts model_info[:capabilities] # => [:chat, :function_calling, :structured_outputs, :vision]
197
+ puts model_info[:cost_per_1k_tokens] # => { input: 0.003, output: 0.015 }
198
+ puts model_info[:context_length] # => 200000
199
+ puts model_info[:performance_tier] # => :premium
200
+
201
+ # Check if model exists
202
+ if OpenRouter::ModelRegistry.model_exists?("anthropic/claude-3.5-sonnet")
203
+ puts "Model is available"
204
+ end
205
+ ```
206
+
207
+ ### Cost Estimation
208
+
209
+ ```ruby
210
+ # Estimate costs for specific token usage
211
+ cost = OpenRouter::ModelRegistry.calculate_estimated_cost(
212
+ "anthropic/claude-3.5-sonnet",
213
+ input_tokens: 1000,
214
+ output_tokens: 500
215
+ )
216
+ puts "Estimated cost: $#{cost.round(4)}" # => "Estimated cost: $0.0105"
217
+ ```
218
+
219
+ ### Filtering Models
220
+
221
+ ```ruby
222
+ # Find models matching specific requirements
223
+ candidates = OpenRouter::ModelRegistry.models_meeting_requirements(
224
+ capabilities: [:function_calling, :vision],
225
+ max_input_cost: 0.01,
226
+ min_context_length: 50_000
227
+ )
228
+
229
+ candidates.each do |model_id, specs|
230
+ puts "#{model_id}: $#{specs[:cost_per_1k_tokens][:input]} per 1k tokens"
231
+ end
232
+ ```
233
+
234
+ ### Cache Management
235
+
236
+ ```ruby
237
+ # Refresh model data from API (clears cache)
238
+ OpenRouter::ModelRegistry.refresh!
239
+
240
+ # Clear cache manually
241
+ OpenRouter::ModelRegistry.clear_cache!
242
+
243
+ # Check cache status
244
+ cached_data = OpenRouter::ModelRegistry.load_cached_models
245
+ puts cached_data ? "Cache loaded" : "No cache available"
246
+ ```
247
+
248
+ ## Advanced Usage Patterns
249
+
250
+ ### Cost-Aware Model Selection
251
+
252
+ ```ruby
253
+ def select_model_by_budget(requirements, max_monthly_cost:, estimated_monthly_tokens:)
254
+ max_cost_per_1k = (max_monthly_cost / estimated_monthly_tokens) * 1000
255
+
256
+ OpenRouter::ModelSelector.new
257
+ .require(*requirements)
258
+ .within_budget(max_cost: max_cost_per_1k)
259
+ .optimize_for(:cost)
260
+ .choose
261
+ end
262
+
263
+ # Usage
264
+ model = select_model_by_budget(
265
+ [:function_calling],
266
+ max_monthly_cost: 100.0, # $100 monthly budget
267
+ estimated_monthly_tokens: 1_000_000 # 1M tokens per month
268
+ )
269
+ ```
270
+
271
+ ### Capability-First Selection
272
+
273
+ ```ruby
274
+ def select_best_model_for_task(task_type)
275
+ requirements = case task_type
276
+ when :data_extraction
277
+ [:structured_outputs]
278
+ when :function_calling
279
+ [:function_calling]
280
+ when :document_analysis
281
+ [:vision, :long_context]
282
+ when :code_generation
283
+ [:function_calling, :long_context]
284
+ else
285
+ []
286
+ end
287
+
288
+ OpenRouter::ModelSelector.new
289
+ .require(*requirements)
290
+ .optimize_for(:performance)
291
+ .choose_with_fallback
292
+ end
293
+ ```
294
+
295
+ ### Provider Rotation
296
+
297
+ ```ruby
298
+ class ModelRotator
299
+ def initialize(providers:, requirements: [])
300
+ @providers = providers
301
+ @requirements = requirements
302
+ @current_index = 0
303
+ end
304
+
305
+ def next_model
306
+ provider = @providers[@current_index % @providers.length]
307
+ @current_index += 1
308
+
309
+ OpenRouter::ModelSelector.new
310
+ .require(*@requirements)
311
+ .require_providers(provider)
312
+ .optimize_for(:cost)
313
+ .choose
314
+ end
315
+ end
316
+
317
+ # Usage
318
+ rotator = ModelRotator.new(
319
+ providers: ["anthropic", "openai", "google"],
320
+ requirements: [:function_calling]
321
+ )
322
+
323
+ 3.times do
324
+ model = rotator.next_model
325
+ puts "Using model: #{model}"
326
+ end
327
+ ```
328
+
329
+ ### Performance Monitoring
330
+
331
+ ```ruby
332
+ class ModelPerformanceTracker
333
+ def initialize
334
+ @performance_data = {}
335
+ end
336
+
337
+ def track_completion(model, input_tokens:, output_tokens:, duration:, success:)
338
+ @performance_data[model] ||= {
339
+ calls: 0,
340
+ successes: 0,
341
+ total_duration: 0,
342
+ total_cost: 0
343
+ }
344
+
345
+ data = @performance_data[model]
346
+ data[:calls] += 1
347
+ data[:successes] += 1 if success
348
+ data[:total_duration] += duration
349
+
350
+ cost = OpenRouter::ModelRegistry.calculate_estimated_cost(
351
+ model,
352
+ input_tokens: input_tokens,
353
+ output_tokens: output_tokens
354
+ )
355
+ data[:total_cost] += cost
356
+ end
357
+
358
+ def best_performing_model(min_calls: 5)
359
+ eligible_models = @performance_data.select { |_, data| data[:calls] >= min_calls }
360
+ return nil if eligible_models.empty?
361
+
362
+ eligible_models.max_by do |_, data|
363
+ success_rate = data[:successes].to_f / data[:calls]
364
+ avg_duration = data[:total_duration] / data[:calls]
365
+
366
+ # Score based on success rate and speed (higher is better)
367
+ success_rate * 100 - avg_duration
368
+ end&.first
369
+ end
370
+ end
371
+ ```
372
+
373
+ ## Integration with Client
374
+
375
+ ### Automatic Model Selection
376
+
377
+ ```ruby
378
+ class SmartOpenRouterClient < OpenRouter::Client
379
+ def initialize(*args, **kwargs)
380
+ super
381
+ @model_selector = OpenRouter::ModelSelector.new
382
+ end
383
+
384
+ def smart_complete(messages, requirements: [], **options)
385
+ unless options[:model]
386
+ options[:model] = @model_selector
387
+ .require(*requirements)
388
+ .optimize_for(:cost)
389
+ .choose_with_fallback
390
+ end
391
+
392
+ complete(messages, **options)
393
+ end
394
+ end
395
+
396
+ # Usage
397
+ client = SmartOpenRouterClient.new
398
+ response = client.smart_complete(
399
+ messages,
400
+ requirements: [:function_calling],
401
+ tools: tools
402
+ )
403
+ ```
404
+
405
+ ### Fallback Chains
406
+
407
+ ```ruby
408
+ def complete_with_fallbacks(messages, **options)
409
+ models = OpenRouter::ModelSelector.new
410
+ .require(:function_calling)
411
+ .optimize_for(:cost)
412
+ .choose_with_fallbacks(limit: 3)
413
+
414
+ last_error = nil
415
+
416
+ models.each do |model|
417
+ begin
418
+ return client.complete(messages, model: model, **options)
419
+ rescue OpenRouter::ServerError => e
420
+ last_error = e
421
+ puts "Model #{model} failed: #{e.message}, trying next..."
422
+ next
423
+ end
424
+ end
425
+
426
+ raise last_error if last_error
427
+ raise OpenRouter::Error, "No models available"
428
+ end
429
+ ```
430
+
431
+ ## Configuration and Customization
432
+
433
+ ### Custom Selection Criteria
434
+
435
+ ```ruby
436
+ class CustomModelSelector < OpenRouter::ModelSelector
437
+ def require_custom_capability(capability_name)
438
+ # Add custom logic for proprietary capability detection
439
+ new_requirements = @requirements.dup
440
+ new_requirements[:custom_capabilities] ||= []
441
+ new_requirements[:custom_capabilities] << capability_name
442
+
443
+ self.class.new(
444
+ requirements: new_requirements,
445
+ strategy: @strategy,
446
+ provider_preferences: @provider_preferences,
447
+ fallback_options: @fallback_options
448
+ )
449
+ end
450
+
451
+ private
452
+
453
+ def meets_requirements?(specs, requirements)
454
+ # Call parent implementation first
455
+ return false unless super
456
+
457
+ # Add custom requirement checking
458
+ if requirements[:custom_capabilities]
459
+ # Your custom logic here
460
+ end
461
+
462
+ true
463
+ end
464
+ end
465
+ ```
466
+
467
+ ### Environment-Specific Defaults
468
+
469
+ ```ruby
470
+ # config/initializers/openrouter.rb
471
+ class OpenRouter::ModelSelector
472
+ class << self
473
+ def production_defaults
474
+ new.avoid_patterns("*-preview", "*-alpha", "*-beta")
475
+ .require_providers("anthropic", "openai") # Trusted providers only
476
+ .optimize_for(:performance)
477
+ end
478
+
479
+ def development_defaults
480
+ new.optimize_for(:cost)
481
+ .within_budget(max_cost: 0.001) # Keep costs low in dev
482
+ end
483
+
484
+ def for_environment
485
+ Rails.env.production? ? production_defaults : development_defaults
486
+ end
487
+ end
488
+ end
489
+
490
+ # Usage in your app
491
+ model = OpenRouter::ModelSelector.for_environment
492
+ .require(:function_calling)
493
+ .choose
494
+ ```
495
+
496
+ ## Best Practices
497
+
498
+ ### 1. Cache Selection Results
499
+
500
+ ```ruby
501
+ class CachedModelSelector
502
+ def initialize(ttl: 3600) # 1 hour cache
503
+ @cache = {}
504
+ @ttl = ttl
505
+ end
506
+
507
+ def select(requirements_hash)
508
+ cache_key = requirements_hash.hash
509
+ cached_result = @cache[cache_key]
510
+
511
+ if cached_result && Time.now - cached_result[:timestamp] < @ttl
512
+ return cached_result[:model]
513
+ end
514
+
515
+ model = build_selector_from_hash(requirements_hash).choose
516
+ @cache[cache_key] = { model: model, timestamp: Time.now }
517
+ model
518
+ end
519
+
520
+ private
521
+
522
+ def build_selector_from_hash(hash)
523
+ selector = OpenRouter::ModelSelector.new
524
+
525
+ selector = selector.require(*hash[:capabilities]) if hash[:capabilities]
526
+ selector = selector.within_budget(max_cost: hash[:max_cost]) if hash[:max_cost]
527
+ selector = selector.optimize_for(hash[:strategy]) if hash[:strategy]
528
+
529
+ selector
530
+ end
531
+ end
532
+ ```
533
+
534
+ ### 2. Monitor Model Availability
535
+
536
+ ```ruby
537
+ def check_model_health(models)
538
+ results = {}
539
+
540
+ models.each do |model|
541
+ begin
542
+ # Quick test completion
543
+ response = client.complete(
544
+ [{ role: "user", content: "Say 'OK'" }],
545
+ model: model,
546
+ max_tokens: 5
547
+ )
548
+ results[model] = response.content.include?("OK") ? :healthy : :unhealthy
549
+ rescue => e
550
+ results[model] = :error
551
+ end
552
+ end
553
+
554
+ results
555
+ end
556
+ ```
557
+
558
+ ### 3. Cost Tracking
559
+
560
+ ```ruby
561
+ def track_model_costs
562
+ @daily_costs ||= Hash.new(0)
563
+
564
+ before_action do
565
+ @request_start_time = Time.now
566
+ end
567
+
568
+ after_action do
569
+ if @selected_model && @input_tokens && @output_tokens
570
+ cost = OpenRouter::ModelRegistry.calculate_estimated_cost(
571
+ @selected_model,
572
+ input_tokens: @input_tokens,
573
+ output_tokens: @output_tokens
574
+ )
575
+
576
+ date_key = Date.current.to_s
577
+ @daily_costs[date_key] += cost
578
+
579
+ Rails.logger.info "Model cost: #{@selected_model} $#{cost.round(6)}"
580
+ end
581
+ end
582
+ end
583
+ ```
584
+
585
+ ## Troubleshooting
586
+
587
+ ### Common Issues
588
+
589
+ 1. **No Models Found**: Your requirements may be too restrictive
590
+ 2. **Cache Stale**: Model data might be outdated
591
+ 3. **Network Errors**: API connection issues when fetching model data
592
+ 4. **Performance**: Selection taking too long with many models
593
+
594
+ ### Solutions
595
+
596
+ ```ruby
597
+ # 1. Debug selection criteria
598
+ selector = OpenRouter::ModelSelector.new
599
+ .require(:function_calling)
600
+ .within_budget(max_cost: 0.0001) # Very restrictive
601
+
602
+ puts "Selection criteria:"
603
+ puts selector.selection_criteria.inspect
604
+
605
+ # See what models meet each requirement step by step
606
+ all_models = OpenRouter::ModelRegistry.all_models
607
+ puts "Total models: #{all_models.size}"
608
+
609
+ with_capability = OpenRouter::ModelRegistry.models_meeting_requirements(
610
+ capabilities: [:function_calling]
611
+ )
612
+ puts "With function calling: #{with_capability.size}"
613
+
614
+ within_budget = with_capability.select do |_, specs|
615
+ specs[:cost_per_1k_tokens][:input] <= 0.0001
616
+ end
617
+ puts "Within budget: #{within_budget.size}"
618
+
619
+ # 2. Force cache refresh
620
+ OpenRouter::ModelRegistry.refresh!
621
+
622
+ # 3. Handle network errors gracefully
623
+ begin
624
+ models = OpenRouter::ModelRegistry.all_models
625
+ rescue OpenRouter::ModelRegistryError => e
626
+ puts "Using fallback model due to registry error: #{e.message}"
627
+ model = "anthropic/claude-3-haiku" # Known reliable fallback
628
+ end
629
+
630
+ # 4. Optimize for performance
631
+ # Use more specific requirements to reduce search space
632
+ model = OpenRouter::ModelSelector.new
633
+ .require_providers("anthropic") # Limit search space
634
+ .require(:function_calling)
635
+ .optimize_for(:cost)
636
+ .choose
637
+ ```