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/docs/tools.md ADDED
@@ -0,0 +1,1016 @@
1
+ # Tool Calling
2
+
3
+ The OpenRouter gem provides comprehensive support for OpenRouter's function calling API with an intuitive Ruby DSL for defining and managing tools. This enables AI models to interact with external functions and APIs in a structured, type-safe manner.
4
+
5
+ ## Quick Start
6
+
7
+ ```ruby
8
+ # Define a tool
9
+ weather_tool = OpenRouter::Tool.define do
10
+ name "get_weather"
11
+ description "Get current weather for a location"
12
+
13
+ parameters do
14
+ string :location, required: true, description: "City name or coordinates"
15
+ string :units, enum: ["celsius", "fahrenheit"], description: "Temperature units"
16
+ end
17
+ end
18
+
19
+ # Use in completion
20
+ response = client.complete(
21
+ [{ role: "user", content: "What's the weather in London?" }],
22
+ model: "anthropic/claude-3.5-sonnet",
23
+ tools: [weather_tool],
24
+ tool_choice: "auto"
25
+ )
26
+
27
+ # Handle tool calls
28
+ if response.has_tool_calls?
29
+ response.tool_calls.each do |tool_call|
30
+ result = fetch_weather(tool_call.arguments["location"])
31
+ puts result
32
+ end
33
+ end
34
+ ```
35
+
36
+ ## Tool Definition DSL
37
+
38
+ The gem provides a fluent DSL for defining tools with comprehensive parameter validation:
39
+
40
+ ### Basic Tool Structure
41
+
42
+ ```ruby
43
+ tool = OpenRouter::Tool.define do
44
+ name "tool_name"
45
+ description "Clear description of what this tool does"
46
+
47
+ parameters do
48
+ # Parameter definitions go here
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### Parameter Types
54
+
55
+ ```ruby
56
+ comprehensive_tool = OpenRouter::Tool.define do
57
+ name "comprehensive_example"
58
+ description "Example showing all parameter types"
59
+
60
+ parameters do
61
+ # String parameters
62
+ string :name, required: true, description: "Required string parameter"
63
+ string :category, enum: ["A", "B", "C"], description: "String with allowed values"
64
+ string :content, minLength: 10, maxLength: 1000, description: "String with length constraints"
65
+ string :pattern_field, pattern: "^[A-Z]{2,3}$", description: "String with regex pattern"
66
+
67
+ # Numeric parameters
68
+ integer :count, required: true, minimum: 1, maximum: 100, description: "Integer with range"
69
+ number :price, minimum: 0.01, description: "Floating point number"
70
+
71
+ # Boolean parameters
72
+ boolean :enabled, description: "Boolean flag"
73
+
74
+ # Array parameters
75
+ array :tags, description: "Array of strings", items: { type: "string" }
76
+ array :numbers, description: "Array of numbers", items: { type: "number", minimum: 0 }
77
+
78
+ # Object parameters (nested)
79
+ object :metadata do
80
+ string :key, required: true
81
+ string :value, required: true
82
+ integer :priority, minimum: 1, maximum: 10
83
+ end
84
+ end
85
+ end
86
+ ```
87
+
88
+ ### Complex Nested Objects
89
+
90
+ ```ruby
91
+ order_processing_tool = OpenRouter::Tool.define do
92
+ name "process_order"
93
+ description "Process a customer order"
94
+
95
+ parameters do
96
+ string :order_id, required: true, description: "Unique order identifier"
97
+
98
+ # Customer object
99
+ object :customer, required: true do
100
+ string :name, required: true, description: "Customer full name"
101
+ string :email, required: true, description: "Customer email address"
102
+
103
+ # Nested address object
104
+ object :address, required: true do
105
+ string :street, required: true
106
+ string :city, required: true
107
+ string :state, required: true
108
+ string :zip_code, required: true, pattern: "^\\d{5}(-\\d{4})?$"
109
+ string :country, default: "US", enum: ["US", "CA", "MX"]
110
+ end
111
+ end
112
+
113
+ # Array of order items
114
+ array :items, required: true, description: "Order items" do
115
+ items do
116
+ object do
117
+ string :product_id, required: true
118
+ string :product_name, required: true
119
+ integer :quantity, required: true, minimum: 1
120
+ number :unit_price, required: true, minimum: 0.01
121
+ array :options, description: "Product options", items: { type: "string" }
122
+ end
123
+ end
124
+ end
125
+
126
+ # Payment information
127
+ object :payment do
128
+ string :method, required: true, enum: ["credit_card", "debit_card", "paypal", "bank_transfer"]
129
+ number :amount, required: true, minimum: 0.01
130
+ string :currency, default: "USD", enum: ["USD", "EUR", "GBP", "CAD"]
131
+ end
132
+
133
+ # Optional metadata
134
+ object :metadata do
135
+ string :source, enum: ["web", "mobile", "api"]
136
+ string :campaign_id
137
+ boolean :gift_order, default: false
138
+ string :special_instructions
139
+ end
140
+ end
141
+ end
142
+ ```
143
+
144
+ ### Array Parameters with Complex Items
145
+
146
+ ```ruby
147
+ data_analysis_tool = OpenRouter::Tool.define do
148
+ name "analyze_data"
149
+ description "Analyze datasets with various configurations"
150
+
151
+ parameters do
152
+ # Array of simple values
153
+ array :column_names, required: true, description: "Data column names", items: { type: "string" }
154
+
155
+ # Array of numbers with constraints
156
+ array :thresholds, description: "Analysis thresholds", items: {
157
+ type: "number",
158
+ minimum: 0,
159
+ maximum: 100
160
+ }
161
+
162
+ # Array of objects - use explicit items hash for complex objects
163
+ array :filters, description: "Data filters", items: {
164
+ type: "object",
165
+ properties: {
166
+ column: { type: "string", description: "Column to filter" },
167
+ operator: { type: "string", enum: ["eq", "ne", "gt", "lt", "in", "not_in"] },
168
+ value_type: { type: "string", enum: ["string", "number", "array"] },
169
+ value: { type: "string", description: "Filter value (format depends on value_type)" }
170
+ },
171
+ required: ["column", "operator", "value_type"],
172
+ additionalProperties: false
173
+ }
174
+
175
+ # Array with min/max items
176
+ array :metrics, required: true, description: "Metrics to calculate",
177
+ minItems: 1, maxItems: 10, items: {
178
+ type: "string",
179
+ enum: ["mean", "median", "mode", "std", "var", "min", "max"]
180
+ }
181
+ end
182
+ end
183
+ ```
184
+
185
+ ## Tool Definition from Hash
186
+
187
+ For complex tools or when migrating from existing OpenAPI/JSON schemas:
188
+
189
+ ```ruby
190
+ # Define from hash (useful for complex tools or API imports)
191
+ api_tool = OpenRouter::Tool.from_hash({
192
+ name: "api_request",
193
+ description: "Make HTTP requests to external APIs",
194
+ parameters: {
195
+ type: "object",
196
+ properties: {
197
+ url: {
198
+ type: "string",
199
+ format: "uri",
200
+ description: "Target URL for the request"
201
+ },
202
+ method: {
203
+ type: "string",
204
+ enum: ["GET", "POST", "PUT", "DELETE", "PATCH"],
205
+ default: "GET"
206
+ },
207
+ headers: {
208
+ type: "object",
209
+ patternProperties: {
210
+ "^[A-Za-z-]+$": { type: "string" }
211
+ },
212
+ description: "HTTP headers as key-value pairs"
213
+ },
214
+ body: {
215
+ type: "string",
216
+ description: "Request body (JSON string for POST/PUT)"
217
+ },
218
+ timeout: {
219
+ type: "integer",
220
+ minimum: 1,
221
+ maximum: 300,
222
+ default: 30,
223
+ description: "Request timeout in seconds"
224
+ }
225
+ },
226
+ required: ["url"]
227
+ }
228
+ })
229
+
230
+ # Convert from OpenAPI 3.0 spec
231
+ def tool_from_openapi_operation(operation_spec)
232
+ OpenRouter::Tool.from_hash({
233
+ name: operation_spec[:operationId],
234
+ description: operation_spec[:summary] || operation_spec[:description],
235
+ parameters: operation_spec.dig(:requestBody, :content, :"application/json", :schema) || {
236
+ type: "object",
237
+ properties: {},
238
+ required: []
239
+ }
240
+ })
241
+ end
242
+ ```
243
+
244
+ ## Using Tools in Completions
245
+
246
+ ### Basic Usage
247
+
248
+ ```ruby
249
+ tools = [weather_tool, calculator_tool, search_tool]
250
+
251
+ response = client.complete(
252
+ [{ role: "user", content: "What's the weather in Tokyo and what's 15 * 23?" }],
253
+ model: "anthropic/claude-3.5-sonnet",
254
+ tools: tools,
255
+ tool_choice: "auto" # Let the model decide which tools to use
256
+ )
257
+ ```
258
+
259
+ ### Tool Choice Options
260
+
261
+ ```ruby
262
+ # Auto - let model decide when to use tools
263
+ response = client.complete(messages, tools: tools, tool_choice: "auto")
264
+
265
+ # Required - force model to use a tool
266
+ response = client.complete(messages, tools: tools, tool_choice: "required")
267
+
268
+ # None - disable tool usage for this request
269
+ response = client.complete(messages, tools: tools, tool_choice: "none")
270
+
271
+ # Specific tool - force use of a particular tool
272
+ response = client.complete(messages, tools: tools, tool_choice: "get_weather")
273
+ ```
274
+
275
+ ### Handling Tool Calls
276
+
277
+ ```ruby
278
+ def handle_completion_with_tools(messages, tools)
279
+ response = client.complete(messages, tools: tools, tool_choice: "auto")
280
+
281
+ unless response.has_tool_calls?
282
+ return response.content # Regular text response
283
+ end
284
+
285
+ # Process each tool call
286
+ conversation = messages.dup
287
+ conversation << response.to_message
288
+
289
+ response.tool_calls.each do |tool_call|
290
+ puts "Executing tool: #{tool_call.name}"
291
+ puts "Arguments: #{tool_call.arguments.inspect}"
292
+
293
+ # Execute the tool
294
+ result = execute_tool(tool_call)
295
+
296
+ # Add result to conversation
297
+ conversation << tool_call.to_result_message(result)
298
+ end
299
+
300
+ # Get final response
301
+ final_response = client.complete(conversation, tools: tools)
302
+ final_response.content
303
+ end
304
+
305
+ def execute_tool(tool_call)
306
+ case tool_call.name
307
+ when "get_weather"
308
+ fetch_weather(tool_call.arguments["location"], tool_call.arguments["units"])
309
+ when "calculate"
310
+ perform_calculation(tool_call.arguments["expression"])
311
+ when "search_web"
312
+ search_web(tool_call.arguments["query"], tool_call.arguments["max_results"])
313
+ else
314
+ { error: "Unknown tool: #{tool_call.name}" }
315
+ end
316
+ rescue => e
317
+ { error: "Tool execution failed: #{e.message}" }
318
+ end
319
+ ```
320
+
321
+ ## ToolCall Objects
322
+
323
+ When a model uses tools, you receive `ToolCall` objects with helpful methods:
324
+
325
+ ### ToolCall Object
326
+
327
+ Properties and helpers you can rely on:
328
+
329
+ - `id`: String
330
+ - `type`: String (e.g., "function")
331
+ - `name`: tool function name
332
+ - `arguments_string`: raw JSON string
333
+ - `arguments`: parsed Hash (raises `ToolCallError` on invalid JSON)
334
+ - `to_message`: assistant message with the original `tool_calls` field
335
+ - `to_result_message(result)`: tool message payload with `tool_call_id` and JSON content
336
+ - `execute { |name, arguments| ... }`: returns an `OpenRouter::ToolResult` (success/failure), where:
337
+ - `success?`: boolean
338
+ - `to_message`: a tool message suitable for conversation continuation
339
+
340
+ Example:
341
+
342
+ ```ruby
343
+ response.tool_calls.each do |tool_call|
344
+ tool_result = tool_call.execute do |name, args|
345
+ case name
346
+ when "get_weather" then fetch_weather(args["location"], args["units"])
347
+ else { error: "unknown tool: #{name}" }
348
+ end
349
+ end
350
+
351
+ conversation << tool_result.to_message
352
+ end
353
+ ```
354
+
355
+ ## Advanced Usage Patterns
356
+
357
+ ### Tool Validation and Error Handling
358
+
359
+ ```ruby
360
+ def safe_tool_execution(tool_call, tools)
361
+ # Validate arguments against tool schema
362
+ unless tool_call.valid?(tools: tools)
363
+ return {
364
+ error: "Invalid arguments",
365
+ details: tool_call.validation_errors(tools: tools)
366
+ }
367
+ end
368
+
369
+ begin
370
+ # Execute with timeout
371
+ Timeout::timeout(30) do
372
+ execute_tool_safely(tool_call)
373
+ end
374
+ rescue Timeout::Error
375
+ { error: "Tool execution timed out" }
376
+ rescue => e
377
+ { error: "Tool execution failed: #{e.message}" }
378
+ end
379
+ end
380
+
381
+ def execute_tool_safely(tool_call)
382
+ case tool_call.name
383
+ when "file_operation"
384
+ # Validate file paths for security
385
+ path = tool_call.arguments["path"]
386
+ raise "Invalid path" unless safe_path?(path)
387
+
388
+ File.read(path)
389
+ when "api_request"
390
+ # Validate URLs for security
391
+ url = tool_call.arguments["url"]
392
+ raise "Invalid URL" unless safe_url?(url)
393
+
394
+ Net::HTTP.get(URI(url))
395
+ end
396
+ end
397
+
398
+ def safe_path?(path)
399
+ # Prevent directory traversal
400
+ !path.include?("..") && path.start_with?("/safe/directory/")
401
+ end
402
+
403
+ def safe_url?(url)
404
+ # Only allow specific domains
405
+ uri = URI.parse(url)
406
+ ["api.example.com", "safe-api.com"].include?(uri.host)
407
+ end
408
+ ```
409
+
410
+ ### Streaming Tool Results
411
+
412
+ ```ruby
413
+ def streaming_tool_execution(tool_call)
414
+ case tool_call.name
415
+ when "large_data_processing"
416
+ # Stream results for long-running operations
417
+ results = []
418
+
419
+ process_large_dataset(tool_call.arguments) do |chunk|
420
+ results << chunk
421
+ yield({ status: "processing", progress: results.size, partial_data: chunk })
422
+ end
423
+
424
+ { status: "complete", data: results }
425
+ end
426
+ end
427
+
428
+ # Usage with streaming
429
+ handle_tool_call_with_streaming(tool_call) do |partial_result|
430
+ puts "Progress: #{partial_result[:progress]} items processed"
431
+ end
432
+ ```
433
+
434
+ ### Tool Composition and Chaining
435
+
436
+ ```ruby
437
+ class ToolChain
438
+ def initialize(client, tools)
439
+ @client = client
440
+ @tools = tools
441
+ @conversation = []
442
+ end
443
+
444
+ def execute(initial_message, max_iterations: 10)
445
+ @conversation = [{ role: "user", content: initial_message }]
446
+ iterations = 0
447
+
448
+ while iterations < max_iterations
449
+ response = @client.complete(@conversation, tools: @tools, tool_choice: "auto")
450
+ @conversation << response.to_message
451
+
452
+ if response.has_tool_calls?
453
+ # Execute all tool calls
454
+ response.tool_calls.each do |tool_call|
455
+ result = execute_tool(tool_call)
456
+ @conversation << tool_call.to_result_message(result)
457
+ end
458
+ iterations += 1
459
+ else
460
+ # Final response without tool calls
461
+ return response.content
462
+ end
463
+ end
464
+
465
+ "Maximum iterations reached"
466
+ end
467
+
468
+ private
469
+
470
+ def execute_tool(tool_call)
471
+ # Tool execution logic here
472
+ end
473
+ end
474
+
475
+ # Usage
476
+ chain = ToolChain.new(client, [search_tool, calculator_tool, weather_tool])
477
+ result = chain.execute("Plan a trip to Tokyo: check weather, calculate budget for 5 days, and find flights")
478
+ ```
479
+
480
+ ### Tool Result Caching
481
+
482
+ ```ruby
483
+ class CachedToolExecutor
484
+ def initialize(cache_ttl: 3600) # 1 hour cache
485
+ @cache = {}
486
+ @cache_ttl = cache_ttl
487
+ end
488
+
489
+ def execute(tool_call)
490
+ cache_key = generate_cache_key(tool_call)
491
+ cached_result = @cache[cache_key]
492
+
493
+ if cached_result && Time.now - cached_result[:timestamp] < @cache_ttl
494
+ return cached_result[:result]
495
+ end
496
+
497
+ result = perform_tool_execution(tool_call)
498
+ @cache[cache_key] = { result: result, timestamp: Time.now }
499
+ result
500
+ end
501
+
502
+ private
503
+
504
+ def generate_cache_key(tool_call)
505
+ "#{tool_call.name}:#{tool_call.arguments.to_json}"
506
+ end
507
+
508
+ def perform_tool_execution(tool_call)
509
+ # Actual tool execution
510
+ end
511
+ end
512
+ ```
513
+
514
+ ## Tool Organization and Management
515
+
516
+ ### Tool Registry
517
+
518
+ ```ruby
519
+ class ToolRegistry
520
+ def initialize
521
+ @tools = {}
522
+ end
523
+
524
+ def register(tool)
525
+ @tools[tool.name] = tool
526
+ end
527
+
528
+ def get(name)
529
+ @tools[name]
530
+ end
531
+
532
+ def all
533
+ @tools.values
534
+ end
535
+
536
+ def for_capabilities(*capabilities)
537
+ # Return tools that provide specific capabilities
538
+ @tools.values.select do |tool|
539
+ capabilities.all? { |cap| tool_provides_capability?(tool, cap) }
540
+ end
541
+ end
542
+
543
+ private
544
+
545
+ def tool_provides_capability?(tool, capability)
546
+ # Define your capability mapping logic
547
+ case capability
548
+ when :web_search
549
+ ["search_web", "google_search"].include?(tool.name)
550
+ when :calculations
551
+ ["calculator", "math_eval"].include?(tool.name)
552
+ when :data_analysis
553
+ ["analyze_data", "statistics"].include?(tool.name)
554
+ else
555
+ false
556
+ end
557
+ end
558
+ end
559
+
560
+ # Usage
561
+ registry = ToolRegistry.new
562
+ registry.register(search_tool)
563
+ registry.register(calculator_tool)
564
+ registry.register(weather_tool)
565
+
566
+ # Get tools for specific capabilities
567
+ analysis_tools = registry.for_capabilities(:calculations, :data_analysis)
568
+ ```
569
+
570
+ ### Tool Categories
571
+
572
+ ```ruby
573
+ module Tools
574
+ module Web
575
+ SEARCH = OpenRouter::Tool.define do
576
+ name "web_search"
577
+ description "Search the web for information"
578
+ parameters do
579
+ string :query, required: true
580
+ integer :max_results, minimum: 1, maximum: 20, default: 10
581
+ end
582
+ end
583
+
584
+ SCRAPE = OpenRouter::Tool.define do
585
+ name "web_scrape"
586
+ description "Extract content from a web page"
587
+ parameters do
588
+ string :url, required: true
589
+ array :selectors, items: { type: "string" }
590
+ end
591
+ end
592
+ end
593
+
594
+ module Math
595
+ CALCULATOR = OpenRouter::Tool.define do
596
+ name "calculator"
597
+ description "Perform mathematical calculations"
598
+ parameters do
599
+ string :expression, required: true
600
+ end
601
+ end
602
+
603
+ STATISTICS = OpenRouter::Tool.define do
604
+ name "statistics"
605
+ description "Calculate statistical measures"
606
+ parameters do
607
+ array :data, required: true, items: { type: "number" }
608
+ array :measures, items: {
609
+ type: "string",
610
+ enum: ["mean", "median", "std", "var"]
611
+ }
612
+ end
613
+ end
614
+ end
615
+
616
+ module Data
617
+ FILE_READ = OpenRouter::Tool.define do
618
+ name "file_read"
619
+ description "Read file contents"
620
+ parameters do
621
+ string :path, required: true
622
+ string :encoding, default: "utf-8"
623
+ end
624
+ end
625
+
626
+ DATABASE_QUERY = OpenRouter::Tool.define do
627
+ name "database_query"
628
+ description "Query database"
629
+ parameters do
630
+ string :query, required: true
631
+ array :parameters, items: { type: "string" }
632
+ end
633
+ end
634
+ end
635
+ end
636
+
637
+ # Usage
638
+ web_tools = [Tools::Web::SEARCH, Tools::Web::SCRAPE]
639
+ math_tools = [Tools::Math::CALCULATOR, Tools::Math::STATISTICS]
640
+ all_tools = web_tools + math_tools + [Tools::Data::FILE_READ]
641
+ ```
642
+
643
+ ## Model Selection for Tool Calling
644
+
645
+ ```ruby
646
+ # Select models that support function calling
647
+ model = OpenRouter::ModelSelector.new
648
+ .require(:function_calling)
649
+ .optimize_for(:cost)
650
+ .choose
651
+
652
+ # Use with tools
653
+ response = client.complete(
654
+ messages,
655
+ model: model,
656
+ tools: tools,
657
+ tool_choice: "auto"
658
+ )
659
+ ```
660
+
661
+ ## Testing Tools
662
+
663
+ ### Unit Testing Tool Definitions
664
+
665
+ ```ruby
666
+ # spec/tools/weather_tool_spec.rb
667
+ RSpec.describe "Weather Tool" do
668
+ let(:tool) { Tools::Weather::CURRENT }
669
+
670
+ it "has correct name and description" do
671
+ expect(tool.name).to eq("get_current_weather")
672
+ expect(tool.description).to include("current weather")
673
+ end
674
+
675
+ it "defines required parameters" do
676
+ schema = tool.to_json_schema
677
+ expect(schema[:parameters][:required]).to include("location")
678
+ end
679
+
680
+ it "validates parameter constraints" do
681
+ schema = tool.to_json_schema
682
+ units_prop = schema[:parameters][:properties][:units]
683
+ expect(units_prop[:enum]).to include("celsius", "fahrenheit")
684
+ end
685
+ end
686
+ ```
687
+
688
+ ### Integration Testing
689
+
690
+ ```ruby
691
+ # spec/integration/tool_calling_spec.rb
692
+ RSpec.describe "Tool Calling Integration" do
693
+ let(:client) { OpenRouter::Client.new }
694
+ let(:tools) { [weather_tool, calculator_tool] }
695
+
696
+ it "handles tool calls correctly", :vcr do
697
+ response = client.complete(
698
+ [{ role: "user", content: "What's 2+2 and weather in Paris?" }],
699
+ model: "anthropic/claude-3.5-sonnet",
700
+ tools: tools,
701
+ tool_choice: "auto"
702
+ )
703
+
704
+ expect(response.has_tool_calls?).to be true
705
+ expect(response.tool_calls.map(&:name)).to include("calculator")
706
+ end
707
+
708
+ it "validates tool call arguments" do
709
+ # Mock response with invalid arguments
710
+ mock_response = build_mock_response_with_invalid_tool_call
711
+
712
+ tool_call = mock_response.tool_calls.first
713
+ expect(tool_call.valid?(tools: tools)).to be false
714
+ expect(tool_call.validation_errors(tools: tools)).to include(/required.*missing/)
715
+ end
716
+ end
717
+ ```
718
+
719
+ ### Mock Tool Execution
720
+
721
+ ```ruby
722
+ class MockToolExecutor
723
+ def initialize(responses = {})
724
+ @responses = responses
725
+ end
726
+
727
+ def execute(tool_call)
728
+ key = "#{tool_call.name}:#{tool_call.arguments.to_json}"
729
+ @responses[key] || @responses[tool_call.name] || { error: "No mock response defined" }
730
+ end
731
+ end
732
+
733
+ # Usage in tests
734
+ mock_executor = MockToolExecutor.new({
735
+ "get_weather" => { temperature: 22, condition: "sunny" },
736
+ "calculator" => { result: 42 }
737
+ })
738
+
739
+ result = mock_executor.execute(tool_call)
740
+ expect(result[:temperature]).to eq(22)
741
+ ```
742
+
743
+ ## Security Considerations
744
+
745
+ ### Input Validation
746
+
747
+ ```ruby
748
+ def validate_tool_arguments(tool_call)
749
+ case tool_call.name
750
+ when "file_operation"
751
+ path = tool_call.arguments["path"]
752
+
753
+ # Prevent directory traversal
754
+ raise "Invalid path" if path.include?("..")
755
+
756
+ # Restrict to allowed directories
757
+ allowed_dirs = ["/app/data", "/tmp/uploads"]
758
+ raise "Forbidden path" unless allowed_dirs.any? { |dir| path.start_with?(dir) }
759
+
760
+ when "api_request"
761
+ url = tool_call.arguments["url"]
762
+ uri = URI.parse(url)
763
+
764
+ # Whitelist allowed domains
765
+ allowed_hosts = ["api.example.com", "trusted-service.com"]
766
+ raise "Forbidden host" unless allowed_hosts.include?(uri.host)
767
+
768
+ # Prevent internal network access
769
+ raise "Internal network access forbidden" if internal_ip?(uri.host)
770
+ end
771
+ end
772
+
773
+ def internal_ip?(hostname)
774
+ ip = Resolv.getaddress(hostname)
775
+ IPAddr.new("10.0.0.0/8").include?(ip) ||
776
+ IPAddr.new("172.16.0.0/12").include?(ip) ||
777
+ IPAddr.new("192.168.0.0/16").include?(ip) ||
778
+ IPAddr.new("127.0.0.0/8").include?(ip)
779
+ rescue Resolv::ResolvError
780
+ false
781
+ end
782
+ ```
783
+
784
+ ### Rate Limiting
785
+
786
+ ```ruby
787
+ class RateLimitedToolExecutor
788
+ def initialize(limit: 10, period: 60) # 10 calls per minute
789
+ @limit = limit
790
+ @period = period
791
+ @call_times = []
792
+ end
793
+
794
+ def execute(tool_call)
795
+ now = Time.now
796
+
797
+ # Remove old calls outside the time window
798
+ @call_times.reject! { |time| now - time > @period }
799
+
800
+ # Check rate limit
801
+ if @call_times.size >= @limit
802
+ raise "Rate limit exceeded: #{@limit} calls per #{@period} seconds"
803
+ end
804
+
805
+ @call_times << now
806
+ perform_tool_execution(tool_call)
807
+ end
808
+ end
809
+ ```
810
+
811
+ ### Sandboxing
812
+
813
+ ```ruby
814
+ require 'timeout'
815
+
816
+ def execute_tool_in_sandbox(tool_call)
817
+ # Set resource limits
818
+ original_rlimits = set_resource_limits
819
+
820
+ begin
821
+ # Execute with timeout
822
+ Timeout::timeout(30) do
823
+ case tool_call.name
824
+ when "code_execution"
825
+ execute_code_safely(tool_call.arguments["code"])
826
+ when "file_processing"
827
+ process_file_safely(tool_call.arguments["file_path"])
828
+ else
829
+ execute_tool_normally(tool_call)
830
+ end
831
+ end
832
+ ensure
833
+ # Restore original limits
834
+ restore_resource_limits(original_rlimits)
835
+ end
836
+ end
837
+
838
+ def set_resource_limits
839
+ original = {}
840
+
841
+ # Limit memory usage (100MB)
842
+ original[:memory] = Process.getrlimit(:AS)
843
+ Process.setrlimit(:AS, 100 * 1024 * 1024)
844
+
845
+ # Limit CPU time (10 seconds)
846
+ original[:cpu] = Process.getrlimit(:CPU)
847
+ Process.setrlimit(:CPU, 10)
848
+
849
+ original
850
+ end
851
+ ```
852
+
853
+ ## Best Practices
854
+
855
+ ### 1. Clear Tool Descriptions
856
+
857
+ ```ruby
858
+ # Good: Clear, specific description
859
+ search_tool = OpenRouter::Tool.define do
860
+ name "web_search"
861
+ description "Search the internet for current information about any topic. Returns relevant web pages with titles, URLs, and snippets. Use this when you need up-to-date information not in your training data."
862
+
863
+ parameters do
864
+ string :query, required: true,
865
+ description: "Search query - be specific and include relevant keywords. Example: 'weather forecast London UK' or 'latest iPhone 15 reviews'"
866
+ integer :max_results,
867
+ minimum: 1, maximum: 10, default: 5,
868
+ description: "Number of search results to return (1-10)"
869
+ end
870
+ end
871
+
872
+ # Bad: Vague description
873
+ bad_tool = OpenRouter::Tool.define do
874
+ name "search"
875
+ description "Search for stuff"
876
+ parameters do
877
+ string :q, required: true
878
+ end
879
+ end
880
+ ```
881
+
882
+ ### 2. Parameter Validation
883
+
884
+ ```ruby
885
+ # Use comprehensive validation
886
+ robust_tool = OpenRouter::Tool.define do
887
+ name "user_management"
888
+ description "Manage user accounts"
889
+
890
+ parameters do
891
+ string :action, required: true, enum: ["create", "update", "delete", "get"]
892
+ string :user_id, pattern: "^[a-zA-Z0-9_-]{1,50}$",
893
+ description: "Alphanumeric user ID (1-50 characters)"
894
+ string :email, pattern: "^[^@]+@[^@]+\\.[^@]+$",
895
+ description: "Valid email address"
896
+ integer :age, minimum: 13, maximum: 120,
897
+ description: "User age (13-120)"
898
+ end
899
+ end
900
+ ```
901
+
902
+ ### 3. Error Handling
903
+
904
+ ```ruby
905
+ def robust_tool_execution(tool_call, tools)
906
+ # Validate first
907
+ unless tool_call.valid?(tools: tools)
908
+ return {
909
+ success: false,
910
+ error: "Invalid arguments",
911
+ details: tool_call.validation_errors(tools: tools)
912
+ }
913
+ end
914
+
915
+ begin
916
+ result = execute_tool(tool_call)
917
+ { success: true, data: result }
918
+ rescue => e
919
+ {
920
+ success: false,
921
+ error: "Execution failed: #{e.message}",
922
+ tool_name: tool_call.name,
923
+ timestamp: Time.now.iso8601
924
+ }
925
+ end
926
+ end
927
+ ```
928
+
929
+ ### 4. Tool Documentation
930
+
931
+ ```ruby
932
+ # Document your tools
933
+ module ToolDocumentation
934
+ WEATHER = {
935
+ name: "get_weather",
936
+ description: "Retrieves current weather information",
937
+ usage_examples: [
938
+ "Get weather for a city: { location: 'London' }",
939
+ "Get weather with units: { location: 'Tokyo', units: 'celsius' }"
940
+ ],
941
+ return_format: {
942
+ temperature: "number",
943
+ condition: "string",
944
+ humidity: "number (0-100)",
945
+ wind_speed: "number (km/h)"
946
+ },
947
+ error_cases: [
948
+ "Location not found: returns { error: 'Location not found' }",
949
+ "API unavailable: returns { error: 'Weather service unavailable' }"
950
+ ]
951
+ }
952
+ end
953
+ ```
954
+
955
+ ## Common Tool Patterns
956
+
957
+ ### Database Tools
958
+
959
+ ```ruby
960
+ database_tool = OpenRouter::Tool.define do
961
+ name "database_query"
962
+ description "Execute read-only database queries"
963
+
964
+ parameters do
965
+ string :query, required: true,
966
+ description: "SQL SELECT query (read-only)"
967
+ array :parameters,
968
+ items: { type: "string" },
969
+ description: "Query parameters for prepared statements"
970
+ integer :limit, minimum: 1, maximum: 1000, default: 100,
971
+ description: "Maximum number of rows to return"
972
+ end
973
+ end
974
+ ```
975
+
976
+ ### File System Tools
977
+
978
+ ```ruby
979
+ file_tool = OpenRouter::Tool.define do
980
+ name "file_operations"
981
+ description "Perform file system operations"
982
+
983
+ parameters do
984
+ string :operation, required: true,
985
+ enum: ["read", "write", "list", "exists", "size"]
986
+ string :path, required: true,
987
+ description: "File or directory path"
988
+ string :content,
989
+ description: "Content to write (required for write operation)"
990
+ string :encoding, default: "utf-8",
991
+ enum: ["utf-8", "ascii", "binary"]
992
+ end
993
+ end
994
+ ```
995
+
996
+ ### API Integration Tools
997
+
998
+ ```ruby
999
+ api_tool = OpenRouter::Tool.define do
1000
+ name "external_api"
1001
+ description "Call external REST APIs"
1002
+
1003
+ parameters do
1004
+ string :endpoint, required: true,
1005
+ description: "API endpoint URL"
1006
+ string :method, default: "GET",
1007
+ enum: ["GET", "POST", "PUT", "DELETE"]
1008
+ object :headers,
1009
+ description: "HTTP headers"
1010
+ object :body,
1011
+ description: "Request body for POST/PUT"
1012
+ integer :timeout, minimum: 1, maximum: 300, default: 30,
1013
+ description: "Request timeout in seconds"
1014
+ end
1015
+ end
1016
+ ```