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
data/Rakefile ADDED
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ # Unit tests only (excludes VCR integration tests)
7
+ RSpec::Core::RakeTask.new(:spec) do |t|
8
+ t.exclude_pattern = "spec/vcr/**/*_spec.rb"
9
+ end
10
+
11
+ # VCR integration tests
12
+ RSpec::Core::RakeTask.new(:spec_vcr) do |t|
13
+ t.pattern = "spec/vcr/**/*_spec.rb"
14
+ end
15
+
16
+ # All tests (unit + VCR)
17
+ RSpec::Core::RakeTask.new(:spec_all) do |t|
18
+ # Run all specs
19
+ end
20
+
21
+ require "rubocop/rake_task"
22
+
23
+ RuboCop::RakeTask.new
24
+
25
+ # Default task runs unit tests + rubocop (fast feedback)
26
+ task default: %i[spec rubocop]
27
+
28
+ # Full CI task runs everything
29
+ task ci: %i[spec_all rubocop]
30
+
31
+ # Model exploration tasks
32
+ namespace :models do
33
+ desc "Display summary of available models"
34
+ task :summary do
35
+ require_relative "lib/open_router"
36
+
37
+ puts "\n🤖 OpenRouter Model Registry Summary"
38
+ puts "=" * 80
39
+
40
+ models = OpenRouter::ModelRegistry.all_models
41
+
42
+ # Overall stats
43
+ puts "\n📊 Overall Statistics:"
44
+ puts " Total models: #{models.size}"
45
+
46
+ # Provider breakdown
47
+ providers = models.keys.group_by { |id| id.split("/").first }
48
+ puts "\n🏢 Models by Provider:"
49
+ providers.sort_by { |_, models| -models.size }.each do |provider, provider_models|
50
+ puts " #{provider.ljust(20)} #{provider_models.size} models"
51
+ end
52
+
53
+ # Capabilities breakdown
54
+ all_capabilities = models.values.flat_map { |spec| spec[:capabilities] }.uniq.sort
55
+ puts "\n⚡ Available Capabilities:"
56
+ all_capabilities.each do |cap|
57
+ count = models.values.count { |spec| spec[:capabilities].include?(cap) }
58
+ puts " #{cap.to_s.ljust(25)} #{count} models"
59
+ end
60
+
61
+ # Cost analysis
62
+ input_costs = models.values.map { |spec| spec[:cost_per_1k_tokens][:input] }.compact.sort
63
+ output_costs = models.values.map { |spec| spec[:cost_per_1k_tokens][:output] }.compact.sort
64
+
65
+ puts "\n💰 Cost Analysis (per 1k tokens):"
66
+ puts " Input tokens:"
67
+ puts " Min: $#{format("%.6f", input_costs.min)}"
68
+ puts " Max: $#{format("%.6f", input_costs.max)}"
69
+ puts " Median: $#{format("%.6f", input_costs[input_costs.size / 2])}"
70
+ puts " Output tokens:"
71
+ puts " Min: $#{format("%.6f", output_costs.min)}"
72
+ puts " Max: $#{format("%.6f", output_costs.max)}"
73
+ puts " Median: $#{format("%.6f", output_costs[output_costs.size / 2])}"
74
+
75
+ # Context length analysis
76
+ context_lengths = models.values.map { |spec| spec[:context_length] }.compact.sort
77
+ puts "\n📏 Context Length Analysis:"
78
+ puts " Min: #{format_number_with_commas(context_lengths.min)} tokens"
79
+ puts " Max: #{format_number_with_commas(context_lengths.max)} tokens"
80
+ puts " Median: #{format_number_with_commas(context_lengths[context_lengths.size / 2])} tokens"
81
+
82
+ # Performance tier breakdown
83
+ tiers = models.values.group_by { |spec| spec[:performance_tier] }
84
+ puts "\n🎯 Performance Tiers:"
85
+ tiers.each do |tier, tier_models|
86
+ puts " #{tier.to_s.capitalize.ljust(10)} #{tier_models.size} models"
87
+ end
88
+
89
+ puts "\n#{"=" * 80}"
90
+ puts "💡 Use 'rake models:search' to find specific models"
91
+ puts " Example: rake models:search provider=anthropic capability=function_calling"
92
+ puts
93
+ end
94
+
95
+ desc "Search for models by criteria (provider, capability, cost, context, etc.)"
96
+ task :search do
97
+ require_relative "lib/open_router"
98
+ require "date"
99
+
100
+ args = parse_search_arguments
101
+ puts "\n🔍 Searching OpenRouter Models"
102
+ puts "=" * 80
103
+
104
+ selector = build_model_selector(args)
105
+ limit = args[:limit]&.to_i || 20
106
+
107
+ puts "\n#{"=" * 80}"
108
+ puts "Results (showing up to #{limit}):\n\n"
109
+
110
+ results = fetch_matching_models(selector, args, limit)
111
+ display_search_results(results)
112
+
113
+ puts
114
+
115
+ # Prevent rake from treating arguments as tasks
116
+ ARGV.drop(1).each { |arg| task(arg.to_sym) { nil } }
117
+ end
118
+
119
+ # Parse command-line arguments into a hash
120
+ def self.parse_search_arguments
121
+ ARGV.drop(1).each_with_object({}) do |arg, hash|
122
+ key, value = arg.split("=", 2)
123
+ hash[key.to_sym] = value if key && value
124
+ end
125
+ end
126
+
127
+ # Build a ModelSelector from parsed arguments
128
+ def self.build_model_selector(args)
129
+ selector = OpenRouter::ModelSelector.new
130
+
131
+ selector = apply_optimization_strategy(selector, args)
132
+ selector = apply_provider_filters(selector, args)
133
+ selector = apply_capability_filters(selector, args)
134
+ selector = apply_cost_filters(selector, args)
135
+ selector = apply_context_filter(selector, args)
136
+ apply_date_filter(selector, args)
137
+ end
138
+
139
+ # Apply optimization strategy to selector
140
+ def self.apply_optimization_strategy(selector, args)
141
+ return selector unless args[:optimize]
142
+
143
+ strategy = args[:optimize].to_sym
144
+ selector = selector.optimize_for(strategy)
145
+ puts "📈 Optimizing for: #{strategy}"
146
+ selector
147
+ end
148
+
149
+ # Apply provider filters to selector
150
+ def self.apply_provider_filters(selector, args)
151
+ return selector unless args[:provider]
152
+
153
+ providers = args[:provider].split(",").map(&:strip)
154
+ selector = selector.require_providers(*providers)
155
+ puts "🏢 Provider: #{providers.join(", ")}"
156
+ selector
157
+ end
158
+
159
+ # Apply capability filters to selector
160
+ def self.apply_capability_filters(selector, args)
161
+ capability_arg = args[:capability] || args[:capabilities]
162
+ return selector unless capability_arg
163
+
164
+ caps = capability_arg.split(",").map { |c| c.strip.to_sym }
165
+ selector = selector.require(*caps)
166
+ puts "⚡ Required capabilities: #{caps.join(", ")}"
167
+ selector
168
+ end
169
+
170
+ # Apply cost filters to selector
171
+ def self.apply_cost_filters(selector, args)
172
+ selector = apply_input_cost_filter(selector, args)
173
+ apply_output_cost_filter(selector, args)
174
+ end
175
+
176
+ # Apply input cost filter
177
+ def self.apply_input_cost_filter(selector, args)
178
+ return selector unless args[:max_cost]
179
+
180
+ max_cost = args[:max_cost].to_f
181
+ selector = selector.within_budget(max_cost:)
182
+ puts "💰 Max cost (input): $#{format("%.6f", max_cost)}/1k tokens"
183
+ selector
184
+ end
185
+
186
+ # Apply output cost filter
187
+ def self.apply_output_cost_filter(selector, args)
188
+ return selector unless args[:max_output_cost]
189
+
190
+ max_output_cost = args[:max_output_cost].to_f
191
+ selector = selector.within_budget(max_output_cost:)
192
+ puts "💰 Max cost (output): $#{format("%.6f", max_output_cost)}/1k tokens"
193
+ selector
194
+ end
195
+
196
+ # Apply context length filter to selector
197
+ def self.apply_context_filter(selector, args)
198
+ return selector unless args[:min_context]
199
+
200
+ min_context = args[:min_context].to_i
201
+ selector = selector.min_context(min_context)
202
+ puts "📏 Min context: #{format_number_with_commas(min_context)} tokens"
203
+ selector
204
+ end
205
+
206
+ # Apply date filter to selector
207
+ def self.apply_date_filter(selector, args)
208
+ if args[:newer_than]
209
+ date = Date.parse(args[:newer_than])
210
+ selector = selector.newer_than(date)
211
+ puts "📅 Released after: #{date}"
212
+ end
213
+
214
+ puts "🎯 Performance tier: #{args[:tier]}" if args[:tier]
215
+ selector
216
+ end
217
+
218
+ # Fetch matching models based on selector and arguments
219
+ def self.fetch_matching_models(selector, args, limit)
220
+ if args[:fallbacks]
221
+ selector.choose_with_fallbacks(limit:)
222
+ else
223
+ fetch_sorted_candidates(selector, limit)
224
+ end
225
+ end
226
+
227
+ # Fetch and sort all matching candidates
228
+ def self.fetch_sorted_candidates(selector, limit)
229
+ requirements = selector.instance_variable_get(:@requirements)
230
+ provider_preferences = selector.instance_variable_get(:@provider_preferences)
231
+ strategy = selector.instance_variable_get(:@strategy)
232
+
233
+ candidates = OpenRouter::ModelRegistry.models_meeting_requirements(requirements)
234
+ candidates = filter_by_provider_preferences(candidates, provider_preferences)
235
+ sorted = sort_by_strategy(candidates, strategy)
236
+
237
+ sorted.first(limit)
238
+ end
239
+
240
+ # Display search results
241
+ def self.display_search_results(results)
242
+ if results.empty?
243
+ display_no_results
244
+ else
245
+ display_model_results(results)
246
+ end
247
+ end
248
+
249
+ # Display message when no results found
250
+ def self.display_no_results
251
+ puts "❌ No models found matching your criteria"
252
+ puts "\n💡 Try relaxing your requirements or use different filters"
253
+ end
254
+
255
+ # Display list of model results
256
+ def self.display_model_results(results)
257
+ results.each_with_index do |(model_id, specs), index|
258
+ next unless specs
259
+
260
+ specs = OpenRouter::ModelRegistry.get_model_info(model_id) if model_id.is_a?(String) && !specs
261
+ display_model_info(model_id, specs, index)
262
+ end
263
+
264
+ puts "=" * 80
265
+ puts "Found #{results.size} matching model#{"s" if results.size != 1}"
266
+ end
267
+
268
+ # Display information for a single model
269
+ def self.display_model_info(model_id, specs, index)
270
+ puts "#{(index + 1).to_s.rjust(3)}. #{model_id}"
271
+ puts " Name: #{specs[:name]}" if specs[:name]
272
+ puts " Cost: $#{format("%.6f", specs[:cost_per_1k_tokens][:input])}/1k input, " \
273
+ "$#{format("%.6f", specs[:cost_per_1k_tokens][:output])}/1k output"
274
+ puts " Context: #{format_number_with_commas(specs[:context_length])} tokens"
275
+ puts " Capabilities: #{specs[:capabilities].join(", ")}"
276
+ puts " Tier: #{specs[:performance_tier]}"
277
+
278
+ display_release_date(specs[:created_at]) if specs[:created_at]
279
+ puts
280
+ end
281
+
282
+ # Display release date for a model
283
+ def self.display_release_date(created_at)
284
+ created = Time.at(created_at).strftime("%Y-%m-%d")
285
+ puts " Released: #{created}"
286
+ end
287
+
288
+ # Format number with comma separators
289
+ def self.format_number_with_commas(number)
290
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
291
+ end
292
+
293
+ # Helper methods for search task
294
+ def self.filter_by_provider_preferences(candidates, preferences)
295
+ return candidates if preferences.empty?
296
+
297
+ filtered = candidates.dup
298
+
299
+ if preferences[:required]
300
+ required_providers = preferences[:required]
301
+ filtered = filtered.select do |model_id, _|
302
+ provider = model_id.split("/").first
303
+ required_providers.include?(provider)
304
+ end
305
+ end
306
+
307
+ if preferences[:avoided]
308
+ avoided_providers = preferences[:avoided]
309
+ filtered = filtered.reject do |model_id, _|
310
+ provider = model_id.split("/").first
311
+ avoided_providers.include?(provider)
312
+ end
313
+ end
314
+
315
+ filtered
316
+ end
317
+
318
+ def self.sort_by_strategy(candidates, strategy)
319
+ case strategy
320
+ when :cost
321
+ candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
322
+ when :performance
323
+ candidates.sort_by do |_, specs|
324
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
325
+ end
326
+ when :latest
327
+ candidates.sort_by { |_, specs| -(specs[:created_at] || 0).to_i }
328
+ when :context
329
+ candidates.sort_by { |_, specs| -(specs[:context_length] || 0).to_i }
330
+ else
331
+ candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
332
+ end
333
+ end
334
+ end
data/SECURITY.md ADDED
@@ -0,0 +1,150 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ We release patches for security vulnerabilities for the following versions:
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | 1.x.x | :white_check_mark: |
10
+ | < 1.0 | :x: |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ We take the security of OpenRouter Enhanced seriously. If you believe you have found a security vulnerability, please report it to us as described below.
15
+
16
+ ### Where to Report
17
+
18
+ **Please do not report security vulnerabilities through public GitHub issues.**
19
+
20
+ Instead, please report them via email to:
21
+
22
+ - **Email**: opensource@ericstiens.dev (replace with actual contact)
23
+ - **Subject Line**: `[SECURITY] OpenRouter Enhanced - Brief Description`
24
+
25
+ Alternatively, you can use GitHub's private vulnerability reporting feature:
26
+
27
+ 1. Go to the repository's Security tab
28
+ 2. Click "Report a vulnerability"
29
+ 3. Fill out the form with details
30
+
31
+ ### What to Include
32
+
33
+ Please include the following information in your report:
34
+
35
+ - Type of issue
36
+ - Full paths of source file(s) related to the issue
37
+ - Location of the affected source code (tag/branch/commit or direct URL)
38
+ - Any special configuration required to reproduce the issue
39
+ - Step-by-step instructions to reproduce the issue
40
+ - Proof-of-concept or exploit code (if possible)
41
+ - Impact of the issue, including how an attacker might exploit it
42
+
43
+ ### What to Expect
44
+
45
+ After you submit a report:
46
+
47
+ 1. **Acknowledgment**: We will acknowledge receipt of your vulnerability report within 48 hours.
48
+
49
+ 2. **Initial Assessment**: We will perform an initial assessment and respond with our evaluation within 5 business days.
50
+
51
+ 3. **Updates**: We will keep you informed about our progress towards resolving the issue.
52
+
53
+ 4. **Resolution**: Once the issue is resolved, we will:
54
+ - Release a security patch
55
+ - Publicly disclose the vulnerability (with credit to you, if desired)
56
+ - Add the issue to our security advisories
57
+
58
+ ### Disclosure Policy
59
+
60
+ - We ask that you do not publicly disclose the vulnerability until we have had a chance to address it.
61
+ - We will coordinate with you on the disclosure timeline.
62
+ - We aim to resolve critical issues within 30 days of acknowledgment.
63
+
64
+ ## Security Best Practices for Users
65
+
66
+ When using the OpenRouter Enhanced gem:
67
+
68
+ 1. **API Keys**:
69
+ - Never hardcode API keys in your code
70
+ - Use environment variables or secure credential storage
71
+ - Rotate API keys regularly
72
+ - Never commit API keys to version control
73
+
74
+ 2. **Dependency Management**:
75
+ - Keep the gem updated to the latest version
76
+ - Regularly run `bundle update` to get security patches
77
+ - Monitor security advisories for dependencies
78
+
79
+ 3. **Input Validation**:
80
+ - Validate all user inputs before passing to the gem
81
+ - Be cautious with tool calling and structured outputs from untrusted sources
82
+ - Sanitize data before execution
83
+
84
+ 4. **Network Security**:
85
+ - Use HTTPS for all API communications (enabled by default)
86
+ - Verify SSL certificates (enabled by default)
87
+ - Be cautious with proxy configurations
88
+
89
+ 5. **Error Handling**:
90
+ - Avoid exposing detailed error messages to end users
91
+ - Log errors securely without exposing sensitive data
92
+ - Monitor for unusual error patterns
93
+
94
+ ## Known Security Considerations
95
+
96
+ ### API Key Storage
97
+
98
+ The gem requires an OpenRouter API key for operation. Users are responsible for:
99
+ - Securely storing their API keys
100
+ - Not committing keys to version control
101
+ - Using environment variables or secure vaults
102
+
103
+ ### Tool Calling
104
+
105
+ When using tool calling features:
106
+ - Validate tool arguments before execution
107
+ - Implement proper authorization checks
108
+ - Sandbox tool execution where appropriate
109
+ - Never execute arbitrary code from LLM responses without validation
110
+
111
+ ### Data Privacy
112
+
113
+ When using the gem:
114
+ - Be aware that data is sent to OpenRouter's API
115
+ - Review OpenRouter's privacy policy and terms of service
116
+ - Implement appropriate data handling for sensitive information
117
+ - Consider data residency requirements for your use case
118
+
119
+ ## Security Update Process
120
+
121
+ 1. Security issues are prioritized based on severity and impact
122
+ 2. Patches are developed and tested in private
123
+ 3. Security advisories are prepared
124
+ 4. Patches are released via a new gem version
125
+ 5. Security advisories are published
126
+ 6. Users are notified through:
127
+ - GitHub Security Advisories
128
+ - RubyGems security notifications
129
+ - Project changelog
130
+ - Release notes
131
+
132
+ ## Bug Bounty Program
133
+
134
+ We currently do not have a bug bounty program. However, we greatly appreciate security researchers who responsibly disclose vulnerabilities and will publicly acknowledge their contributions (with permission).
135
+
136
+ ## Contact
137
+
138
+ For general security questions or concerns, please use the same contact methods as vulnerability reporting.
139
+
140
+ For non-security-related issues, please use the standard GitHub issues process.
141
+
142
+ ## Acknowledgments
143
+
144
+ We would like to thank the following individuals for responsibly disclosing security issues:
145
+
146
+ (This section will be updated as security issues are responsibly disclosed and resolved)
147
+
148
+ ---
149
+
150
+ Last updated: 2025-10-05
@@ -0,0 +1,80 @@
1
+ # VCR Configuration
2
+
3
+ This document explains the VCR (Video Cassette Recorder) configuration for testing external API interactions.
4
+
5
+ ## Configuration Overview
6
+
7
+ The VCR setup is configured in `spec/support/vcr.rb` with the following key features:
8
+
9
+ ### Environment-Based Recording Modes
10
+
11
+ - **CI Environment** (`CI=true`, `GITHUB_ACTIONS=true`, or `CONTINUOUS_INTEGRATION=true`):
12
+ - Recording mode: `:none` (never record, use existing cassettes only)
13
+ - HTTP connections: Disabled (WebMock blocks all external requests)
14
+ - API Key: Uses dummy key from environment variable
15
+
16
+ - **Development Environment**:
17
+ - Recording mode: `:once` (record if cassette doesn't exist, otherwise use existing)
18
+ - HTTP connections: Allowed when no cassette exists (enables recording)
19
+ - API Key: Uses real API key from environment variable
20
+
21
+ ### Override Options
22
+
23
+ - `VCR_RECORD_ALL=true`: Re-record all cassettes (development only)
24
+ - `VCR_RECORD_NEW=true`: Record new episodes only (development only)
25
+
26
+ ### API Key Handling
27
+
28
+ - In CI: Uses dummy key `"dummy-api-key-for-testing-do-not-use"` set in GitHub Actions
29
+ - In development: Uses real API key from `OPENROUTER_API_KEY` environment variable
30
+ - All API keys are filtered from recordings for security
31
+
32
+ ### Cassette Storage
33
+
34
+ Cassettes are stored in `spec/fixtures/vcr_cassettes/` and contain recorded HTTP interactions.
35
+
36
+ ## Usage
37
+
38
+ ### Running Tests with Different Modes
39
+
40
+ ```bash
41
+ # Use existing cassettes (CI mode)
42
+ CI=true bundle exec rspec spec/vcr/
43
+
44
+ # Record new cassettes (development)
45
+ OPENROUTER_API_KEY=your-real-key bundle exec rspec spec/vcr/
46
+
47
+ # Re-record all cassettes
48
+ VCR_RECORD_ALL=true OPENROUTER_API_KEY=your-real-key bundle exec rspec spec/vcr/
49
+ ```
50
+
51
+ ### Test Tagging
52
+
53
+ Tests use `:vcr` metadata tag to enable VCR recording:
54
+
55
+ ```ruby
56
+ RSpec.describe "API Tests", :vcr do
57
+ it "makes API call", vcr: { cassette_name: "custom_name" } do
58
+ # Test code that makes HTTP requests
59
+ end
60
+ end
61
+ ```
62
+
63
+ ## Security
64
+
65
+ - All API keys are automatically filtered from cassette recordings
66
+ - Real API keys are never committed to the repository
67
+ - CI uses dummy keys that cannot access real services
68
+
69
+ ## WebMock Integration
70
+
71
+ VCR hooks into WebMock to:
72
+ - Block external HTTP requests by default
73
+ - Allow requests only when VCR cassettes are recording
74
+ - Ensure tests are deterministic and don't depend on external services
75
+
76
+ This configuration ensures that:
77
+ 1. Tests are fast and reliable (no external dependencies)
78
+ 2. CI environments never make real API calls
79
+ 3. Development can record new interactions when needed
80
+ 4. Security is maintained through API key filtering