agentbill-sdk 2.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,164 @@
1
+ # AgentBill Customers Resource - CRUD operations
2
+ #
3
+ # Example:
4
+ # ab = AgentBill::Client.init(api_key: 'agb_...')
5
+ #
6
+ # # List customers
7
+ # result = ab.customers.list(limit: 10)
8
+ #
9
+ # # Create customer
10
+ # customer = ab.customers.create(name: 'Acme', email: 'a@b.com')
11
+ #
12
+ # # Get customer by ID
13
+ # customer = ab.customers.get('cust-123')
14
+ #
15
+ # # Update customer
16
+ # customer = ab.customers.update('cust-123', name: 'Acme Inc')
17
+ #
18
+ # # Delete customer
19
+ # ab.customers.delete('cust-123')
20
+
21
+ require 'net/http'
22
+ require 'json'
23
+
24
+ module AgentBill
25
+ class CustomersResource
26
+ def initialize(config)
27
+ @config = config
28
+ @base_url = config[:base_url] || 'https://api.agentbill.io'
29
+ @api_key = config[:api_key]
30
+ @debug = config[:debug] || false
31
+ end
32
+
33
+ # List customers with pagination and search
34
+ #
35
+ # @param limit [Integer] Number of customers to return (default: 50)
36
+ # @param offset [Integer] Offset for pagination (default: 0)
37
+ # @param search [String, nil] Search term to filter by name or email
38
+ # @return [Hash] Hash with 'data' (list of customers) and 'pagination' info
39
+ def list(limit: 50, offset: 0, search: nil)
40
+ uri = URI("#{@base_url}/functions/v1/api-customers")
41
+ params = { limit: limit.to_s, offset: offset.to_s }
42
+ params[:search] = search if search
43
+ uri.query = URI.encode_www_form(params)
44
+
45
+ response = make_request(:get, uri)
46
+ handle_response(response, 'list customers')
47
+ end
48
+
49
+ # Create a new customer
50
+ #
51
+ # @param name [String] Customer name (required)
52
+ # @param email [String] Customer email (required)
53
+ # @param phone [String, nil] Phone number
54
+ # @param website [String, nil] Website URL
55
+ # @param external_id [String, nil] Your external ID for this customer
56
+ # @param metadata [Hash, nil] Additional metadata
57
+ # @return [Hash] Created customer object
58
+ def create(name:, email:, phone: nil, website: nil, external_id: nil, metadata: nil)
59
+ uri = URI("#{@base_url}/functions/v1/api-customers")
60
+
61
+ payload = { name: name, email: email }
62
+ payload[:phone] = phone if phone
63
+ payload[:website] = website if website
64
+ payload[:external_id] = external_id if external_id
65
+ payload[:metadata] = metadata if metadata
66
+
67
+ response = make_request(:post, uri, payload)
68
+ handle_response(response, 'create customer')
69
+ end
70
+
71
+ # Get a customer by ID or external_id
72
+ #
73
+ # @param customer_id [String] Customer UUID or external_id
74
+ # @param by_external_id [Boolean] If true, treat customer_id as external_id
75
+ # @return [Hash] Customer object
76
+ def get(customer_id, by_external_id: false)
77
+ uri = URI("#{@base_url}/functions/v1/api-customers")
78
+ params = by_external_id ? { external_id: customer_id } : { id: customer_id }
79
+ uri.query = URI.encode_www_form(params)
80
+
81
+ response = make_request(:get, uri)
82
+ result = handle_response(response, 'get customer')
83
+
84
+ if result['data'].is_a?(Array)
85
+ raise "Customer not found" if result['data'].empty?
86
+ return result['data'][0]
87
+ end
88
+ result
89
+ end
90
+
91
+ # Update a customer
92
+ #
93
+ # @param customer_id [String] Customer UUID
94
+ # @param name [String, nil] New name
95
+ # @param email [String, nil] New email
96
+ # @param phone [String, nil] New phone
97
+ # @param website [String, nil] New website
98
+ # @param metadata [Hash, nil] New metadata (replaces existing)
99
+ # @return [Hash] Updated customer object
100
+ def update(customer_id, name: nil, email: nil, phone: nil, website: nil, metadata: nil)
101
+ uri = URI("#{@base_url}/functions/v1/api-customers")
102
+
103
+ payload = { id: customer_id }
104
+ payload[:name] = name if name
105
+ payload[:email] = email if email
106
+ payload[:phone] = phone if phone
107
+ payload[:website] = website if website
108
+ payload[:metadata] = metadata if metadata
109
+
110
+ response = make_request(:patch, uri, payload)
111
+ handle_response(response, 'update customer')
112
+ end
113
+
114
+ # Delete a customer
115
+ #
116
+ # @param customer_id [String] Customer UUID to delete
117
+ def delete(customer_id)
118
+ uri = URI("#{@base_url}/functions/v1/api-customers")
119
+ payload = { id: customer_id }
120
+
121
+ response = make_request(:delete, uri, payload)
122
+ handle_response(response, 'delete customer')
123
+ end
124
+
125
+ private
126
+
127
+ def make_request(method, uri, payload = nil)
128
+ http = Net::HTTP.new(uri.host, uri.port)
129
+ http.use_ssl = true
130
+ http.read_timeout = 30
131
+
132
+ case method
133
+ when :get
134
+ request = Net::HTTP::Get.new(uri)
135
+ when :post
136
+ request = Net::HTTP::Post.new(uri.path)
137
+ request.body = payload.to_json if payload
138
+ when :patch
139
+ request = Net::HTTP::Patch.new(uri.path)
140
+ request.body = payload.to_json if payload
141
+ when :delete
142
+ request = Net::HTTP::Delete.new(uri.path)
143
+ request.body = payload.to_json if payload
144
+ end
145
+
146
+ request['Content-Type'] = 'application/json'
147
+ request['X-API-Key'] = @api_key
148
+
149
+ http.request(request)
150
+ end
151
+
152
+ def handle_response(response, operation)
153
+ body = response.body ? JSON.parse(response.body) : {}
154
+
155
+ unless %w[200 201 204].include?(response.code)
156
+ error_msg = body['error'] || "Failed to #{operation}"
157
+ raise error_msg
158
+ end
159
+
160
+ puts "[AgentBill] #{operation} successful" if @debug
161
+ body
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,109 @@
1
+ # AgentBill Distributed Tracing for Ruby
2
+ #
3
+ # Functions for cross-service trace propagation and correlation.
4
+ # This is the SINGLE SOURCE OF TRUTH for trace context in the Ruby SDK.
5
+
6
+ module AgentBill
7
+ module Distributed
8
+ # Thread-local storage for trace context
9
+ @trace_context = {}
10
+ @tracing_token = nil
11
+
12
+ class << self
13
+ # Get the current trace context (trace_id, span_id)
14
+ # This is the SINGLE SOURCE OF TRUTH used by the tracer.
15
+ def get_trace_context
16
+ Thread.current[:agentbill_trace_context]
17
+ end
18
+
19
+ # Set the trace context manually.
20
+ # Called by the tracer after starting a span.
21
+ def set_trace_context(trace_id, span_id)
22
+ Thread.current[:agentbill_trace_context] = {
23
+ trace_id: trace_id,
24
+ span_id: span_id
25
+ }
26
+ end
27
+
28
+ # Clear the current trace context
29
+ def clear_trace_context
30
+ Thread.current[:agentbill_trace_context] = nil
31
+ Thread.current[:agentbill_tracing_token] = nil
32
+ end
33
+
34
+ # Get the current tracing token
35
+ def get_tracing_token
36
+ Thread.current[:agentbill_tracing_token]
37
+ end
38
+
39
+ # Generate a new tracing token for distributed trace propagation
40
+ def generate_tracing_token
41
+ ctx = get_trace_context
42
+ random_suffix = SecureRandom.hex(4)
43
+
44
+ if ctx
45
+ token = "agentbill-v1-#{ctx[:trace_id]}-#{ctx[:span_id]}-#{random_suffix}"
46
+ else
47
+ trace_id = SecureRandom.hex(16)
48
+ span_id = SecureRandom.hex(8)
49
+ set_trace_context(trace_id, span_id)
50
+ token = "agentbill-v1-#{trace_id}-#{span_id}-#{random_suffix}"
51
+ end
52
+
53
+ Thread.current[:agentbill_tracing_token] = token
54
+ token
55
+ end
56
+
57
+ # Set the tracing token received from an upstream service
58
+ def set_tracing_token(token)
59
+ return false unless token&.start_with?('agentbill-v1-')
60
+
61
+ parts = token.split('-')
62
+ return false if parts.length < 5
63
+
64
+ trace_id = parts[2]
65
+ span_id = parts[3]
66
+
67
+ set_trace_context(trace_id, span_id)
68
+ Thread.current[:agentbill_tracing_token] = token
69
+ true
70
+ rescue
71
+ false
72
+ end
73
+
74
+ # Get HTTP headers for trace propagation
75
+ def propagate_trace_headers
76
+ headers = {}
77
+
78
+ token = get_tracing_token
79
+ headers['X-AgentBill-Trace'] = token if token
80
+
81
+ ctx = get_trace_context
82
+ if ctx
83
+ headers['traceparent'] = "00-#{ctx[:trace_id]}-#{ctx[:span_id]}-01"
84
+ end
85
+
86
+ headers
87
+ end
88
+
89
+ # Extract trace context from incoming HTTP headers
90
+ def extract_trace_from_headers(headers)
91
+ # Try AgentBill token first
92
+ token = headers['X-AgentBill-Trace'] || headers['x-agentbill-trace']
93
+ return true if token && set_tracing_token(token)
94
+
95
+ # Try W3C traceparent
96
+ traceparent = headers['traceparent'] || headers['Traceparent']
97
+ if traceparent
98
+ parts = traceparent.split('-')
99
+ if parts.length >= 3
100
+ set_trace_context(parts[1], parts[2])
101
+ return true
102
+ end
103
+ end
104
+
105
+ false
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AgentBill Exceptions
4
+ #
5
+ # Custom exception classes for Cost Guard protection and error handling.
6
+
7
+ module AgentBill
8
+ # Base class for all AgentBill errors
9
+ class AgentBillError < StandardError
10
+ attr_reader :details
11
+
12
+ def initialize(message, details = {})
13
+ @details = details
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ # Raised when a request would exceed the configured budget limits.
19
+ #
20
+ # This is thrown by the context manager or wrapper when ai-cost-guard-router
21
+ # returns allowed=false due to budget constraints.
22
+ #
23
+ # @example
24
+ # begin
25
+ # AgentBill.tracing(customer_id: 'cust-123', daily_budget: 10.00) do |ctx|
26
+ # response = openai.chat.completions.create(...)
27
+ # end
28
+ # rescue AgentBill::BudgetExceededError => e
29
+ # puts "Budget exceeded: #{e.reason}"
30
+ # # Handle gracefully - queue for later, show user message, etc.
31
+ # end
32
+ class BudgetExceededError < AgentBillError
33
+ attr_reader :reason, :code
34
+
35
+ def initialize(reason, details = {})
36
+ @reason = reason
37
+ @code = 'BUDGET_EXCEEDED'
38
+ super("Budget exceeded: #{reason}", details)
39
+ end
40
+ end
41
+
42
+ # Raised when a request would exceed rate limits.
43
+ class RateLimitExceededError < AgentBillError
44
+ attr_reader :reason, :code
45
+
46
+ def initialize(reason, details = {})
47
+ @reason = reason
48
+ @code = 'RATE_LIMIT_EXCEEDED'
49
+ super("Rate limit exceeded: #{reason}", details)
50
+ end
51
+ end
52
+
53
+ # Raised when a request violates a Cost Guard policy.
54
+ class PolicyViolationError < AgentBillError
55
+ attr_reader :reason, :code
56
+
57
+ def initialize(reason, details = {})
58
+ @reason = reason
59
+ @code = 'POLICY_VIOLATION'
60
+ super("Policy violation: #{reason}", details)
61
+ end
62
+ end
63
+
64
+ # Raised when input validation fails.
65
+ class ValidationError < AgentBillError
66
+ attr_reader :field, :code
67
+
68
+ def initialize(message, field = nil)
69
+ @field = field
70
+ @code = 'VALIDATION_ERROR'
71
+ super("Validation error: #{message}")
72
+ end
73
+ end
74
+
75
+ # Raised when tracing operations fail.
76
+ class TracingError < AgentBillError
77
+ attr_reader :code
78
+
79
+ def initialize(message, details = {})
80
+ @code = 'TRACING_ERROR'
81
+ super("Tracing error: #{message}", details)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,153 @@
1
+ # Ollama Wrapper for AgentBill Ruby SDK (Local, $0 cost)
2
+ require_relative 'pricing'
3
+
4
+ module AgentBill
5
+ class OllamaWrapper
6
+ def initialize(client, config, tracer)
7
+ @client = client
8
+ @config = config
9
+ @tracer = tracer
10
+ end
11
+
12
+ def wrap
13
+ wrap_chat
14
+ wrap_generate
15
+ @client
16
+ end
17
+
18
+ private
19
+
20
+ def wrap_chat
21
+ original_chat = @client.method(:chat)
22
+ config = @config
23
+ tracer = @tracer
24
+
25
+ @client.define_singleton_method(:chat) do |params|
26
+ model = params[:model] || 'llama2'
27
+
28
+ start_time = Time.now
29
+ span = tracer.start_span('ollama.chat', {
30
+ # ✅ OTEL GenAI compliant attributes
31
+ 'gen_ai.system' => 'ollama',
32
+ 'gen_ai.request.model' => model,
33
+ 'gen_ai.operation.name' => 'chat',
34
+ # ⚠️ Backward compatibility
35
+ 'model' => model,
36
+ 'provider' => 'ollama'
37
+ })
38
+
39
+ begin
40
+ response = original_chat.call(params)
41
+ latency = ((Time.now - start_time) * 1000).round
42
+
43
+ input_tokens = response[:prompt_eval_count] || 0
44
+ output_tokens = response[:eval_count] || 0
45
+
46
+ track_usage(model, 'ollama', input_tokens, output_tokens, latency, 0, config)
47
+
48
+ span.set_attributes({
49
+ # ✅ OTEL GenAI compliant attributes
50
+ 'gen_ai.usage.input_tokens' => input_tokens,
51
+ 'gen_ai.usage.output_tokens' => output_tokens,
52
+ # ⚠️ Backward compatibility
53
+ 'response.prompt_eval_count' => input_tokens,
54
+ 'response.eval_count' => output_tokens,
55
+ 'latency_ms' => latency,
56
+ 'cost' => 0
57
+ })
58
+ span.set_status(0)
59
+
60
+ puts "[AgentBill] ✓ Ollama chat tracked (local, $0.00)" if config[:debug]
61
+ response
62
+ rescue => e
63
+ span.set_status(1, e.message)
64
+ raise
65
+ ensure
66
+ span.finish
67
+ end
68
+ end
69
+ end
70
+
71
+ def wrap_generate
72
+ original_generate = @client.method(:generate)
73
+ config = @config
74
+ tracer = @tracer
75
+
76
+ @client.define_singleton_method(:generate) do |params|
77
+ model = params[:model] || 'llama2'
78
+
79
+ start_time = Time.now
80
+ span = tracer.start_span('ollama.generate', {
81
+ # ✅ OTEL GenAI compliant attributes
82
+ 'gen_ai.system' => 'ollama',
83
+ 'gen_ai.request.model' => model,
84
+ 'gen_ai.operation.name' => 'text_completion',
85
+ # ⚠️ Backward compatibility
86
+ 'model' => model,
87
+ 'provider' => 'ollama'
88
+ })
89
+
90
+ begin
91
+ response = original_generate.call(params)
92
+ latency = ((Time.now - start_time) * 1000).round
93
+
94
+ input_tokens = response[:prompt_eval_count] || 0
95
+ output_tokens = response[:eval_count] || 0
96
+
97
+ track_usage(model, 'ollama', input_tokens, output_tokens, latency, 0, config)
98
+
99
+ span.set_attributes({
100
+ # ✅ OTEL GenAI compliant attributes
101
+ 'gen_ai.usage.input_tokens' => input_tokens,
102
+ 'gen_ai.usage.output_tokens' => output_tokens,
103
+ # ⚠️ Backward compatibility
104
+ 'response.prompt_eval_count' => input_tokens,
105
+ 'response.eval_count' => output_tokens,
106
+ 'latency_ms' => latency,
107
+ 'cost' => 0
108
+ })
109
+ span.set_status(0)
110
+
111
+ puts "[AgentBill] ✓ Ollama generate tracked (local, $0.00)" if config[:debug]
112
+ response
113
+ rescue => e
114
+ span.set_status(1, e.message)
115
+ raise
116
+ ensure
117
+ span.finish
118
+ end
119
+ end
120
+ end
121
+
122
+ def track_usage(model, provider, input_tokens, output_tokens, latency, cost, config)
123
+ uri = URI("#{config[:base_url] || 'https://api.agentbill.io'}/functions/v1/track-ai-usage")
124
+
125
+ payload = {
126
+ api_key: config[:api_key],
127
+ customer_id: config[:customer_id],
128
+ agent_id: config[:agent_id],
129
+ event_name: 'ai_request',
130
+ model: model,
131
+ provider: provider,
132
+ prompt_tokens: input_tokens,
133
+ completion_tokens: output_tokens,
134
+ latency_ms: latency,
135
+ cost: cost
136
+ }
137
+
138
+ begin
139
+ http = Net::HTTP.new(uri.host, uri.port)
140
+ http.use_ssl = true
141
+ http.read_timeout = 10
142
+
143
+ request = Net::HTTP::Post.new(uri.path)
144
+ request['Content-Type'] = 'application/json'
145
+ request.body = payload.to_json
146
+
147
+ http.request(request)
148
+ rescue => e
149
+ puts "[AgentBill] Tracking failed: #{e.message}" if config[:debug]
150
+ end
151
+ end
152
+ end
153
+ end