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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +5 -4
- data/CHANGELOG.md +29 -0
- data/examples/ollama_basic.rb +81 -0
- data/examples/perplexity_basic.rb +66 -0
- data/lib/agentbill/agents.rb +226 -0
- data/lib/agentbill/customers.rb +164 -0
- data/lib/agentbill/distributed.rb +109 -0
- data/lib/agentbill/exceptions.rb +84 -0
- data/lib/agentbill/ollama_wrapper.rb +153 -0
- data/lib/agentbill/orders.rb +283 -0
- data/lib/agentbill/perplexity_wrapper.rb +101 -0
- data/lib/agentbill/pricing.rb +52 -0
- data/lib/agentbill/signal_types.rb +179 -0
- data/lib/agentbill/signals.rb +199 -0
- data/lib/agentbill/tracer.rb +68 -11
- data/lib/agentbill/tracing.rb +343 -0
- data/lib/agentbill/version.rb +1 -1
- data/lib/agentbill/wrappers.rb +384 -0
- data/lib/agentbill.rb +252 -45
- metadata +16 -2
|
@@ -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
|
data/lib/agentbill/version.rb
CHANGED