agentbill-sdk 5.0.1 → 7.17.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.
data/lib/agentbill.rb CHANGED
@@ -1,16 +1,24 @@
1
1
  require 'net/http'
2
2
  require 'json'
3
3
  require 'securerandom'
4
+ require 'digest'
4
5
  require_relative 'agentbill/version'
5
6
  require_relative 'agentbill/tracer'
7
+ require_relative 'agentbill/pricing'
8
+ require_relative 'agentbill/customers'
9
+ require_relative 'agentbill/agents'
10
+ require_relative 'agentbill/orders'
6
11
 
7
12
  module AgentBill
8
13
  class Client
9
- attr_reader :config, :tracer
14
+ attr_reader :config, :tracer, :customers, :agents, :orders
10
15
 
11
16
  def initialize(config)
12
17
  @config = config
13
18
  @tracer = Tracer.new(config)
19
+ @customers = CustomersResource.new(config)
20
+ @agents = AgentsResource.new(config)
21
+ @orders = OrdersResource.new(config)
14
22
  end
15
23
 
16
24
  def self.init(config)
@@ -24,33 +32,23 @@ module AgentBill
24
32
  [1, text.to_s.length / 4].max
25
33
  end
26
34
 
27
- def estimate_cost(model, input_tokens, output_tokens)
28
- # Model pricing (per 1K tokens)
29
- pricing = {
30
- 'gpt-4' => { input: 0.03, output: 0.06 },
31
- 'gpt-4o' => { input: 0.005, output: 0.015 },
32
- 'gpt-4o-mini' => { input: 0.00015, output: 0.0006 },
33
- 'claude-sonnet-4-5' => { input: 0.003, output: 0.015 },
34
- 'claude-opus-4-1-20250805' => { input: 0.015, output: 0.075 },
35
- 'claude-3-5-sonnet-20241022' => { input: 0.003, output: 0.015 },
36
- 'mistral-large-latest' => { input: 0.004, output: 0.012 },
37
- 'gemini-pro' => { input: 0.00025, output: 0.0005 }
38
- }
39
-
40
- model_price = pricing[model] || { input: 0.001, output: 0.002 }
41
- (input_tokens / 1000.0 * model_price[:input]) + (output_tokens / 1000.0 * model_price[:output])
35
+ def estimate_cost(model, input_tokens, output_tokens, provider = 'openai')
36
+ Pricing.calculate_cost(model, input_tokens, output_tokens, provider)
42
37
  end
43
38
 
44
39
  def validate_request(model, messages, estimated_output_tokens = 1000)
45
- return { 'allowed' => true } unless @config[:daily_budget] || @config[:monthly_budget]
40
+ # Always validate when customer_id is present - backend will check DB policies
41
+ return { 'allowed' => true } unless @config[:customer_id]
46
42
 
47
- uri = URI("#{@config[:base_url] || 'https://bgwyprqxtdreuutzpbgw.supabase.co'}/functions/v1/ai-cost-guard-router")
43
+ uri = URI("#{@config[:base_url] || 'https://api.agentbill.io'}/functions/v1/ai-cost-guard-router")
48
44
 
49
45
  payload = {
50
46
  api_key: @config[:api_key],
51
47
  customer_id: @config[:customer_id],
52
48
  model: model,
53
- messages: messages
49
+ messages: messages,
50
+ daily_budget_override: @config[:daily_budget],
51
+ monthly_budget_override: @config[:monthly_budget]
54
52
  }
55
53
 
56
54
  begin
@@ -74,20 +72,42 @@ module AgentBill
74
72
  end
75
73
  end
76
74
 
77
- def track_usage(model, provider, input_tokens, output_tokens, latency_ms, cost, event_name = 'ai_request')
78
- uri = URI("#{@config[:base_url] || 'https://bgwyprqxtdreuutzpbgw.supabase.co'}/functions/v1/track-ai-usage")
75
+ # NOTE: track_usage is deprecated in v7.0.2 - signals now go through OTEL collector
76
+ def track_usage(model, provider, input_tokens, output_tokens, latency_ms, cost, event_name = 'ai_request', trace_id = nil, span_id = nil)
77
+ # v7.0.2: Route signals through otel-collector with proper span format
78
+ uri = URI("#{@config[:base_url] || 'https://api.agentbill.io'}/functions/v1/otel-collector")
79
+
80
+ trace_id ||= SecureRandom.hex(16)
81
+ span_id ||= SecureRandom.hex(8)
82
+ now_ns = (Time.now.to_f * 1_000_000_000).to_i.to_s
79
83
 
80
84
  payload = {
81
- api_key: @config[:api_key],
82
- customer_id: @config[:customer_id],
83
- agent_id: @config[:agent_id],
84
- event_name: event_name,
85
- model: model,
86
- provider: provider,
87
- prompt_tokens: input_tokens,
88
- completion_tokens: output_tokens,
89
- latency_ms: latency_ms,
90
- cost: cost
85
+ resourceSpans: [{
86
+ resource: {
87
+ attributes: [
88
+ { key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
89
+ { key: 'agentbill.customer_id', value: { stringValue: @config[:customer_id] || '' } }
90
+ ]
91
+ },
92
+ scopeSpans: [{
93
+ scope: { name: 'agentbill.signals', version: '7.16.1' },
94
+ spans: [{
95
+ traceId: trace_id,
96
+ spanId: span_id,
97
+ name: 'agentbill.trace.signal',
98
+ kind: 1,
99
+ startTimeUnixNano: now_ns,
100
+ endTimeUnixNano: now_ns,
101
+ attributes: [
102
+ { key: 'agentbill.event_name', value: { stringValue: event_name } },
103
+ { key: 'agentbill.is_business_event', value: { boolValue: true } },
104
+ { key: 'gen_ai.request.model', value: { stringValue: model } },
105
+ { key: 'gen_ai.system', value: { stringValue: provider } }
106
+ ],
107
+ status: { code: 1 }
108
+ }]
109
+ }]
110
+ }]
91
111
  }
92
112
 
93
113
  begin
@@ -97,12 +117,13 @@ module AgentBill
97
117
 
98
118
  request = Net::HTTP::Post.new(uri.path)
99
119
  request['Content-Type'] = 'application/json'
120
+ request['x-api-key'] = @config[:api_key]
100
121
  request.body = payload.to_json
101
122
 
102
123
  http.request(request)
103
- puts "[AgentBill Cost Guard] Usage tracked: $#{format('%.4f', cost)}" if @config[:debug]
124
+ puts "[AgentBill] Usage tracked via OTEL: $#{format('%.4f', cost)}" if @config[:debug]
104
125
  rescue => e
105
- puts "[AgentBill Cost Guard] Tracking failed: #{e.message}" if @config[:debug]
126
+ puts "[AgentBill] Tracking failed: #{e.message}" if @config[:debug]
106
127
  end
107
128
  end
108
129
 
@@ -132,9 +153,59 @@ module AgentBill
132
153
  raise error
133
154
  end
134
155
 
156
+ # v7.6.10: Check for cached response from semantic cache
157
+ # CRITICAL FIX: Router sends cost_saved/tokens_saved at top level
158
+ if validation['cached'] && validation['response_data']
159
+ puts "[AgentBill] ✓ Cache hit - returning cached response" if config[:debug]
160
+
161
+ # v7.6.10 FIX: Check both nested 'cache'/'cache_info' AND top-level fields
162
+ cache_info = validation['cache'] || validation['cache_info'] || {}
163
+ cached_response = validation['response_data']
164
+ usage = cached_response['usage'] || {}
165
+
166
+ # v7.6.10 FIX: Extract from cache_info OR top-level validation
167
+ tokens_saved = cache_info['tokens_saved'] || validation['tokens_saved'] || usage['total_tokens'] || 0
168
+ cost_saved = cache_info['cost_saved'] || validation['cost_saved'] || 0
169
+ cached_input_tokens = usage['prompt_tokens'] || (tokens_saved * 0.2).to_i
170
+ cached_output_tokens = usage['completion_tokens'] || (tokens_saved - cached_input_tokens)
171
+ cached_total_tokens = tokens_saved > 0 ? tokens_saved : (cached_input_tokens + cached_output_tokens)
172
+
173
+ # v7.6.10 CRITICAL FIX: Set gen_ai.usage.* to 0 for cache hits
174
+ # This prevents OTEL ingestion from calculating cost for cached responses
175
+ span = tracer.start_span('openai.chat.completion', {
176
+ 'gen_ai.system' => 'openai',
177
+ 'gen_ai.request.model' => model,
178
+ 'gen_ai.operation.name' => 'chat',
179
+ 'model' => model,
180
+ 'provider' => 'openai',
181
+ 'agentbill.from_cache' => true,
182
+ 'agentbill.cache_hit' => true,
183
+ # OTEL cost calculation attributes - set to 0 for cache hits
184
+ 'gen_ai.usage.input_tokens' => 0,
185
+ 'gen_ai.usage.output_tokens' => 0,
186
+ 'gen_ai.usage.total_tokens' => 0,
187
+ # Informational attributes - actual cached token counts
188
+ 'agentbill.cached_input_tokens' => cached_input_tokens,
189
+ 'agentbill.cached_output_tokens' => cached_output_tokens,
190
+ 'agentbill.cached_total_tokens' => cached_total_tokens,
191
+ 'agentbill.tokens_saved' => tokens_saved,
192
+ 'agentbill.cost_saved' => cost_saved
193
+ })
194
+ span.set_status(0)
195
+ span.finish
196
+ tracer.flush
197
+
198
+ return cached_response
199
+ end
200
+
135
201
  # Phase 2: Execute AI call
136
202
  start_time = Time.now
137
203
  span = tracer.start_span('openai.chat.completion', {
204
+ # ✅ OTEL GenAI compliant attributes
205
+ 'gen_ai.system' => 'openai',
206
+ 'gen_ai.request.model' => model,
207
+ 'gen_ai.operation.name' => 'chat',
208
+ # ⚠️ Backward compatibility
138
209
  'model' => model,
139
210
  'provider' => 'openai'
140
211
  })
@@ -143,14 +214,19 @@ module AgentBill
143
214
  response = original_method.call(params)
144
215
  latency = ((Time.now - start_time) * 1000).round
145
216
 
146
- # Phase 3: Track actual usage
217
+ # NOTE: wrap() only creates OTEL spans, NOT signals
218
+ # Signals should be created explicitly via track_signal()
147
219
  input_tokens = response.dig(:usage, :prompt_tokens) || 0
148
220
  output_tokens = response.dig(:usage, :completion_tokens) || 0
149
221
  cost = config[:_client].send(:estimate_cost, model, input_tokens, output_tokens)
150
222
 
151
- config[:_client].send(:track_usage, model, 'openai', input_tokens, output_tokens, latency, cost, event_name)
152
-
153
223
  span.set_attributes({
224
+ # ✅ OTEL GenAI compliant attributes
225
+ 'gen_ai.usage.input_tokens' => input_tokens,
226
+ 'gen_ai.usage.output_tokens' => output_tokens,
227
+ 'gen_ai.usage.total_tokens' => response.dig(:usage, :total_tokens),
228
+ 'gen_ai.response.id' => response.dig(:id),
229
+ # ⚠️ Backward compatibility
154
230
  'response.prompt_tokens' => input_tokens,
155
231
  'response.completion_tokens' => output_tokens,
156
232
  'response.total_tokens' => response.dig(:usage, :total_tokens),
@@ -158,16 +234,24 @@ module AgentBill
158
234
  })
159
235
  span.set_status(0)
160
236
 
161
- puts "[AgentBill Cost Guard] ✓ Protected call completed: $#{format('%.4f', cost)}" if config[:debug]
237
+ puts "[AgentBill] ✓ OTEL span created: $#{format('%.4f', cost)} (use track_signal() for signals)" if config[:debug]
238
+
239
+ # v7.5.0: Cache AI response for semantic caching
240
+ response_content = response.dig(:choices, 0, :message, :content) || ''
241
+ prompt_text = messages.map { |m| m[:content] }.join(' ')
242
+ prompt_hash = Digest::SHA256.hexdigest(prompt_text)[0..15]
243
+ config[:_client].send(:cache_response, model, prompt_hash, response_content, input_tokens + output_tokens, cost)
244
+
162
245
  response
163
246
  rescue => e
164
247
  span.set_status(1, e.message)
165
248
  raise
166
249
  ensure
167
250
  span.finish
251
+ # Auto-flush spans to prevent data loss
252
+ tracer.flush
168
253
  end
169
254
  end
170
-
171
255
  # Store reference to self for helper methods
172
256
  @config[:_client] = self
173
257
  client
@@ -196,6 +280,11 @@ module AgentBill
196
280
  # Phase 2: Execute AI call
197
281
  start_time = Time.now
198
282
  span = tracer.start_span('anthropic.message', {
283
+ # ✅ OTEL GenAI compliant attributes
284
+ 'gen_ai.system' => 'anthropic',
285
+ 'gen_ai.request.model' => model,
286
+ 'gen_ai.operation.name' => 'chat',
287
+ # ⚠️ Backward compatibility
199
288
  'model' => model,
200
289
  'provider' => 'anthropic'
201
290
  })
@@ -204,30 +293,34 @@ module AgentBill
204
293
  response = original_method.call(params)
205
294
  latency = ((Time.now - start_time) * 1000).round
206
295
 
207
- # Phase 3: Track actual usage
296
+ # NOTE: wrap() only creates OTEL spans, NOT signals
208
297
  input_tokens = response.dig(:usage, :input_tokens) || 0
209
298
  output_tokens = response.dig(:usage, :output_tokens) || 0
210
- cost = config[:_client].send(:estimate_cost, model, input_tokens, output_tokens)
211
-
212
- config[:_client].send(:track_usage, model, 'anthropic', input_tokens, output_tokens, latency, cost)
299
+ cost = config[:_client].send(:estimate_cost, model, input_tokens, output_tokens, 'anthropic')
213
300
 
214
301
  span.set_attributes({
302
+ # ✅ OTEL GenAI compliant attributes
303
+ 'gen_ai.usage.input_tokens' => input_tokens,
304
+ 'gen_ai.usage.output_tokens' => output_tokens,
305
+ 'gen_ai.response.id' => response.dig(:id),
306
+ # ⚠️ Backward compatibility
215
307
  'response.input_tokens' => input_tokens,
216
308
  'response.output_tokens' => output_tokens,
217
309
  'latency_ms' => latency
218
310
  })
219
311
  span.set_status(0)
220
312
 
221
- puts "[AgentBill Cost Guard] ✓ Protected call completed: $#{format('%.4f', cost)}" if config[:debug]
313
+ puts "[AgentBill] ✓ OTEL span created: $#{format('%.4f', cost)} (use track_signal() for signals)" if config[:debug]
222
314
  response
223
315
  rescue => e
224
316
  span.set_status(1, e.message)
225
317
  raise
226
318
  ensure
227
319
  span.finish
320
+ # Auto-flush spans
321
+ tracer.flush
228
322
  end
229
323
  end
230
-
231
324
  # Store reference to self for helper methods
232
325
  @config[:_client] = self
233
326
  client
@@ -274,7 +367,7 @@ module AgentBill
274
367
  def track_signal(**params)
275
368
  raise ArgumentError, "event_name is required" unless params[:event_name]
276
369
 
277
- uri = URI("#{@config[:base_url] || 'https://bgwyprqxtdreuutzpbgw.supabase.co'}/functions/v1/record-signals")
370
+ uri = URI("#{@config[:base_url] || 'https://api.agentbill.io'}/functions/v1/record-signals")
278
371
 
279
372
  # Add timestamp if not provided
280
373
  params[:timestamp] ||= Time.now.to_f
@@ -319,8 +412,122 @@ module AgentBill
319
412
  end
320
413
  end
321
414
 
415
+ # Track a conversion event for prompt profitability analysis
416
+ # Links conversions to AI prompts to calculate ROI per prompt
417
+ #
418
+ # @param event_type [String] Type of conversion (e.g., 'purchase', 'signup', 'trial_start')
419
+ # @param event_value [Float] Revenue amount from the conversion
420
+ # @param signal_id [String, nil] Optional UUID linking to specific AI signal/prompt
421
+ # @param session_id [String, nil] Optional session identifier
422
+ # @param attribution_window_hours [Integer] Time window for attribution (default: 24 hours)
423
+ # @param currency [String] Currency code (default: 'USD')
424
+ # @param metadata [Hash, nil] Optional additional data
425
+ # @return [Hash] Response with success status and conversion_id
426
+ #
427
+ # Example:
428
+ # # Track a purchase conversion
429
+ # result = agentbill.track_conversion(
430
+ # event_type: 'purchase',
431
+ # event_value: 99.99,
432
+ # currency: 'USD'
433
+ # )
434
+ #
435
+ # # Link conversion to specific AI prompt
436
+ # result = agentbill.track_conversion(
437
+ # event_type: 'trial_signup',
438
+ # event_value: 29.99,
439
+ # signal_id: 'signal-uuid-from-prompt',
440
+ # session_id: 'session-123'
441
+ # )
442
+ def track_conversion(event_type:, event_value:, signal_id: nil, session_id: nil, attribution_window_hours: 24, currency: 'USD', metadata: nil)
443
+ uri = URI("#{@config[:base_url] || 'https://api.agentbill.io'}/functions/v1/track-conversion")
444
+
445
+ payload = {
446
+ api_key: @config[:api_key],
447
+ customer_id: @config[:customer_id],
448
+ event_type: event_type,
449
+ event_value: event_value,
450
+ signal_id: signal_id,
451
+ session_id: session_id,
452
+ attribution_window_hours: attribution_window_hours,
453
+ currency: currency,
454
+ metadata: metadata
455
+ }
456
+
457
+ begin
458
+ http = Net::HTTP.new(uri.host, uri.port)
459
+ http.use_ssl = true
460
+
461
+ request = Net::HTTP::Post.new(uri.path)
462
+ request['Content-Type'] = 'application/json'
463
+ request.body = payload.to_json
464
+
465
+ response = http.request(request)
466
+ data = JSON.parse(response.body)
467
+
468
+ if response.code != '200'
469
+ return {
470
+ success: false,
471
+ error: data['error'] || "HTTP #{response.code}"
472
+ }
473
+ end
474
+
475
+ if @config[:debug]
476
+ puts "[AgentBill] Conversion tracked: #{event_type} = $#{event_value} (ID: #{data['conversion_id']})"
477
+ end
478
+
479
+ {
480
+ success: true,
481
+ conversion_id: data['conversion_id']
482
+ }
483
+ rescue => e
484
+ if @config[:debug]
485
+ puts "[AgentBill] Failed to track conversion: #{e.message}"
486
+ end
487
+ {
488
+ success: false,
489
+ error: e.message
490
+ }
491
+ end
492
+ end
493
+
322
494
  def flush
323
495
  @tracer.flush
324
496
  end
497
+
498
+ private
499
+
500
+ # Cache AI response for semantic caching (v7.5.0)
501
+ def cache_response(model, prompt_hash, response_content, tokens_used = 0, cost = 0.0)
502
+ return if response_content.nil? || response_content.empty?
503
+
504
+ uri = URI("#{@config[:base_url] || 'https://api.agentbill.io'}/functions/v1/cache-ai-response")
505
+
506
+ payload = {
507
+ api_key: @config[:api_key],
508
+ prompt_hash: prompt_hash,
509
+ response_content: response_content,
510
+ model: model,
511
+ tokens_used: tokens_used,
512
+ cost: cost,
513
+ customer_id: @config[:customer_id],
514
+ agent_id: @config[:agent_id]
515
+ }
516
+
517
+ begin
518
+ http = Net::HTTP.new(uri.host, uri.port)
519
+ http.use_ssl = true
520
+ http.read_timeout = 5
521
+
522
+ request = Net::HTTP::Post.new(uri.path)
523
+ request['Content-Type'] = 'application/json'
524
+ request.body = payload.to_json
525
+
526
+ http.request(request)
527
+ puts "[AgentBill] Response cached for semantic caching" if @config[:debug]
528
+ rescue => e
529
+ puts "[AgentBill] Cache response failed: #{e.message}" if @config[:debug]
530
+ end
531
+ end
325
532
  end
326
533
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentbill-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.1
4
+ version: 7.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - AgentBill
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-06 00:00:00.000000000 Z
11
+ date: 2026-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -65,12 +65,26 @@ files:
65
65
  - agentbill.gemspec
66
66
  - examples/anthropic_basic.rb
67
67
  - examples/manual_otel_tracking.rb
68
+ - examples/ollama_basic.rb
68
69
  - examples/openai_basic.rb
69
70
  - examples/openai_custom_event.rb
71
+ - examples/perplexity_basic.rb
70
72
  - examples/zero_config.rb
71
73
  - lib/agentbill.rb
74
+ - lib/agentbill/agents.rb
75
+ - lib/agentbill/customers.rb
76
+ - lib/agentbill/distributed.rb
77
+ - lib/agentbill/exceptions.rb
78
+ - lib/agentbill/ollama_wrapper.rb
79
+ - lib/agentbill/orders.rb
80
+ - lib/agentbill/perplexity_wrapper.rb
81
+ - lib/agentbill/pricing.rb
82
+ - lib/agentbill/signal_types.rb
83
+ - lib/agentbill/signals.rb
72
84
  - lib/agentbill/tracer.rb
85
+ - lib/agentbill/tracing.rb
73
86
  - lib/agentbill/version.rb
87
+ - lib/agentbill/wrappers.rb
74
88
  homepage: https://github.com/Agent-Bill/Ruby
75
89
  licenses:
76
90
  - MIT