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.
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+
8
+ require_relative 'exceptions'
9
+ require_relative 'distributed'
10
+ require_relative 'signals'
11
+
12
+ module AgentBill
13
+ # Context manager for tracking AI operations with Cost Guard protection.
14
+ #
15
+ # Implements the flow from the sequence diagram:
16
+ # 1. Pre-validate with ai-cost-guard-router
17
+ # 2. If blocked, raise BudgetExceededError
18
+ # 3. If allowed, execute AI calls
19
+ # 4. After completion, send spans to otel-collector
20
+ class TracingContext
21
+ attr_reader :trace_id, :span_id, :parent_span_id, :request_id, :start_time, :validation_result
22
+
23
+ BASE_URL = 'https://api.agentbill.io'
24
+ VERSION = '7.16.1'
25
+
26
+ def initialize(options = {})
27
+ @api_key = options[:api_key] || ENV['AGENTBILL_API_KEY']
28
+ @customer_id = options[:customer_id]
29
+ @daily_budget = options[:daily_budget]
30
+ @monthly_budget = options[:monthly_budget]
31
+ @agent_id = options[:agent_id]
32
+ @base_url = options[:base_url] || BASE_URL
33
+ @debug = options[:debug] || false
34
+ @model = options[:model] || 'gpt-4'
35
+ @estimated_tokens = options[:estimated_tokens] || 1000
36
+
37
+ @trace_id = nil
38
+ @span_id = nil
39
+ @parent_span_id = nil
40
+ @request_id = nil
41
+ @start_time = nil
42
+ @validation_result = nil
43
+ @spans = []
44
+ end
45
+
46
+ def enter
47
+ generate_ids
48
+ @start_time = Time.now
49
+
50
+ # Set global config for signal() function
51
+ Signals.set_config(
52
+ api_key: @api_key,
53
+ customer_id: @customer_id,
54
+ agent_id: @agent_id,
55
+ base_url: @base_url,
56
+ debug: @debug
57
+ )
58
+
59
+ # CRITICAL: Pre-validate with Cost Guard
60
+ @validation_result = validate_budget
61
+
62
+ # CRITICAL: Raise exception if not allowed
63
+ unless @validation_result['allowed']
64
+ reason = @validation_result['reason'] || 'Request blocked by Cost Guard'
65
+ reason_lower = reason.downcase
66
+
67
+ if reason_lower.include?('budget')
68
+ raise BudgetExceededError.new(reason, @validation_result)
69
+ elsif reason_lower.include?('rate')
70
+ raise RateLimitExceededError.new(reason, @validation_result)
71
+ else
72
+ raise PolicyViolationError.new(reason, @validation_result)
73
+ end
74
+ end
75
+
76
+ self
77
+ end
78
+
79
+ def exit(error = nil)
80
+ end_time = Time.now
81
+
82
+ # Add root span for the entire context
83
+ add_span(
84
+ 'agentbill_tracing',
85
+ {
86
+ 'customer_id' => @customer_id,
87
+ 'agent_id' => @agent_id,
88
+ 'request_id' => @request_id,
89
+ 'error' => error&.message
90
+ },
91
+ (@start_time.to_f * 1_000_000_000).to_i,
92
+ (end_time.to_f * 1_000_000_000).to_i,
93
+ error ? 1 : 0
94
+ )
95
+
96
+ # Export spans to otel-collector
97
+ export_spans
98
+
99
+ # Clear trace context
100
+ Distributed.clear_trace_context
101
+ end
102
+
103
+ def add_span(name, attributes, start_time_ns, end_time_ns, status = 0)
104
+ span = {
105
+ 'trace_id' => @trace_id,
106
+ 'span_id' => SecureRandom.hex(8),
107
+ 'parent_span_id' => @span_id,
108
+ 'name' => name,
109
+ 'attributes' => attributes,
110
+ 'start_time_unix_nano' => start_time_ns,
111
+ 'end_time_unix_nano' => end_time_ns,
112
+ 'status' => { 'code' => status }
113
+ }
114
+ @spans << span
115
+ end
116
+
117
+ def cache_response(model, messages, response_content, input_tokens = 0, output_tokens = 0)
118
+ prompt_hash = hash_prompt(messages)
119
+ url = URI("#{@base_url}/functions/v1/cache-ai-response")
120
+
121
+ payload = {
122
+ api_key: @api_key,
123
+ prompt_hash: prompt_hash,
124
+ response_content: response_content,
125
+ model: model,
126
+ prompt_content: messages.is_a?(String) ? messages : JSON.generate(messages),
127
+ tokens_used: input_tokens + output_tokens,
128
+ cacheable: true,
129
+ ttl_hours: 24,
130
+ customer_id: @customer_id,
131
+ request_id: @request_id
132
+ }
133
+
134
+ begin
135
+ http = Net::HTTP.new(url.host, url.port)
136
+ http.use_ssl = true
137
+ http.read_timeout = 5
138
+
139
+ request = Net::HTTP::Post.new(url)
140
+ request['Content-Type'] = 'application/json'
141
+ request.body = JSON.generate(payload)
142
+
143
+ response = http.request(request)
144
+ log("✓ Cached response: #{response.code}") if @debug
145
+ rescue StandardError => e
146
+ log("⚠️ Cache error (non-blocking): #{e.message}") if @debug
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def log(message)
153
+ puts "[AgentBill] #{message}" if @debug
154
+ end
155
+
156
+ def generate_ids
157
+ existing = Distributed.get_trace_context
158
+
159
+ if existing && existing[:trace_id]
160
+ @trace_id = existing[:trace_id]
161
+ @parent_span_id = existing[:span_id]
162
+ else
163
+ @trace_id = SecureRandom.hex(16)
164
+ @parent_span_id = nil
165
+ end
166
+
167
+ @span_id = SecureRandom.hex(8)
168
+ Distributed.set_trace_context(@trace_id, @span_id)
169
+ end
170
+
171
+ def validate_budget
172
+ url = URI("#{@base_url}/functions/v1/ai-cost-guard-router")
173
+
174
+ payload = {
175
+ api_key: @api_key,
176
+ customer_id: @customer_id,
177
+ model: @model,
178
+ messages: [],
179
+ estimated_tokens: @estimated_tokens
180
+ }
181
+
182
+ payload[:daily_budget_override] = @daily_budget if @daily_budget
183
+ payload[:monthly_budget_override] = @monthly_budget if @monthly_budget
184
+ payload[:agent_id] = @agent_id if @agent_id
185
+
186
+ begin
187
+ http = Net::HTTP.new(url.host, url.port)
188
+ http.use_ssl = true
189
+ http.read_timeout = 10
190
+
191
+ request = Net::HTTP::Post.new(url)
192
+ request['Content-Type'] = 'application/json'
193
+ request.body = JSON.generate(payload)
194
+
195
+ response = http.request(request)
196
+
197
+ if response.code.to_i >= 500
198
+ log('⚠️ Router server error (failing open)') if @debug
199
+ return { 'allowed' => true, 'reason' => 'Router server error (failed open)' }
200
+ end
201
+
202
+ if response.code.to_i >= 400
203
+ log("❌ Router rejected: #{response.body}") if @debug
204
+ return { 'allowed' => false, 'reason' => response.body }
205
+ end
206
+
207
+ result = JSON.parse(response.body)
208
+ @request_id = result['request_id']
209
+
210
+ if @debug
211
+ if result['allowed']
212
+ log('✓ Budget validation passed')
213
+ else
214
+ log("❌ Budget validation failed: #{result['reason']}")
215
+ end
216
+ end
217
+
218
+ result
219
+ rescue StandardError => e
220
+ log("⚠️ Router network error: #{e.message} (failing open)") if @debug
221
+ { 'allowed' => true, 'reason' => 'Router network error (failed open)' }
222
+ end
223
+ end
224
+
225
+ def export_spans
226
+ return if @spans.empty?
227
+
228
+ url = URI("#{@base_url}/functions/v1/otel-collector")
229
+
230
+ resource_attributes = [
231
+ { key: 'service.name', value: { stringValue: 'agentbill-ruby-sdk' } },
232
+ { key: 'service.version', value: { stringValue: VERSION } }
233
+ ]
234
+
235
+ resource_attributes << { key: 'customer.id', value: { stringValue: @customer_id } } if @customer_id
236
+ resource_attributes << { key: 'agent.id', value: { stringValue: @agent_id } } if @agent_id
237
+
238
+ otlp_spans = @spans.map do |span|
239
+ otlp_span = {
240
+ traceId: span['trace_id'],
241
+ spanId: span['span_id'],
242
+ name: span['name'],
243
+ kind: 1,
244
+ startTimeUnixNano: span['start_time_unix_nano'].to_s,
245
+ endTimeUnixNano: span['end_time_unix_nano'].to_s,
246
+ attributes: (span['attributes'] || {}).map do |k, v|
247
+ value = case v
248
+ when String then { stringValue: v }
249
+ when Integer then { intValue: v }
250
+ when Float then { doubleValue: v }
251
+ else { stringValue: v.to_s }
252
+ end
253
+ { key: k, value: value }
254
+ end,
255
+ status: span['status']
256
+ }
257
+ otlp_span[:parentSpanId] = span['parent_span_id'] if span['parent_span_id']
258
+ otlp_span
259
+ end
260
+
261
+ payload = {
262
+ resourceSpans: [{
263
+ resource: { attributes: resource_attributes },
264
+ scopeSpans: [{
265
+ scope: { name: 'agentbill', version: VERSION },
266
+ spans: otlp_spans
267
+ }]
268
+ }]
269
+ }
270
+
271
+ begin
272
+ http = Net::HTTP.new(url.host, url.port)
273
+ http.use_ssl = true
274
+ http.read_timeout = 10
275
+
276
+ request = Net::HTTP::Post.new(url)
277
+ request['Content-Type'] = 'application/json'
278
+ request['X-API-Key'] = @api_key
279
+ request.body = JSON.generate(payload)
280
+
281
+ response = http.request(request)
282
+
283
+ if @debug
284
+ if response.code.to_i == 200
285
+ log("✓ Exported #{@spans.length} spans to otel-collector")
286
+ else
287
+ log("⚠️ Span export failed: #{response.code}")
288
+ end
289
+ end
290
+ rescue StandardError => e
291
+ log("⚠️ Span export error: #{e.message}") if @debug
292
+ end
293
+ end
294
+
295
+ def hash_prompt(messages)
296
+ content = messages.is_a?(String) ? messages : JSON.generate(messages)
297
+ Digest::SHA256.hexdigest(content)
298
+ end
299
+ end
300
+
301
+ class << self
302
+ # Context manager for tracking AI operations with Cost Guard protection.
303
+ #
304
+ # @example
305
+ # AgentBill.tracing(customer_id: 'cust-123', api_key: 'ab_xxx') do |ctx|
306
+ # response = openai.chat.completions.create(...)
307
+ # AgentBill.signal('chat_completed', revenue: 0.50)
308
+ # end
309
+ #
310
+ # @raise [BudgetExceededError] If daily/monthly budget would be exceeded
311
+ # @raise [RateLimitExceededError] If rate limits would be exceeded
312
+ # @raise [PolicyViolationError] If other policy constraints are violated
313
+ def tracing(customer_id:, api_key: nil, daily_budget: nil, monthly_budget: nil,
314
+ agent_id: nil, base_url: nil, debug: false, model: nil, estimated_tokens: 1000)
315
+ effective_api_key = api_key || ENV['AGENTBILL_API_KEY']
316
+ raise ArgumentError, 'api_key is required. Provide it as argument or set AGENTBILL_API_KEY env var.' unless effective_api_key
317
+
318
+ ctx = TracingContext.new(
319
+ api_key: effective_api_key,
320
+ customer_id: customer_id,
321
+ daily_budget: daily_budget,
322
+ monthly_budget: monthly_budget,
323
+ agent_id: agent_id,
324
+ base_url: base_url,
325
+ debug: debug,
326
+ model: model,
327
+ estimated_tokens: estimated_tokens
328
+ )
329
+
330
+ begin
331
+ ctx.enter
332
+ yield ctx
333
+ rescue BudgetExceededError, RateLimitExceededError, PolicyViolationError
334
+ raise
335
+ rescue StandardError => e
336
+ ctx.exit(e)
337
+ raise
338
+ else
339
+ ctx.exit
340
+ end
341
+ end
342
+ end
343
+ end
@@ -1,3 +1,3 @@
1
1
  module AgentBill
2
- VERSION = "5.0.1"
2
+ VERSION = "7.17.0"
3
3
  end