agentbill-sdk 5.0.1 → 9.4.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,133 @@
1
+ # Ollama Wrapper for AgentBill Ruby SDK (Local, $0 cost)
2
+ # v7.17.3: Fixed to use OTEL spans instead of deleted track-ai-usage endpoint
3
+
4
+
5
+ module AgentBill
6
+ class OllamaWrapper
7
+ def initialize(client, config, tracer)
8
+ @client = client
9
+ @config = config
10
+ @tracer = tracer
11
+ end
12
+
13
+ def wrap
14
+ wrap_chat
15
+ wrap_generate
16
+ @client
17
+ end
18
+
19
+ private
20
+
21
+ def wrap_chat
22
+ original_chat = @client.method(:chat)
23
+ config = @config
24
+ tracer = @tracer
25
+
26
+ @client.define_singleton_method(:chat) do |params|
27
+ model = params[:model] || 'llama2'
28
+
29
+ start_time = Time.now
30
+ span = tracer.start_span('ollama.chat', {
31
+ # ✅ OTEL GenAI compliant attributes
32
+ 'gen_ai.system' => 'ollama',
33
+ 'gen_ai.request.model' => model,
34
+ 'gen_ai.operation.name' => 'chat',
35
+ # ⚠️ Backward compatibility
36
+ 'model' => model,
37
+ 'provider' => 'ollama'
38
+ })
39
+
40
+ begin
41
+ response = original_chat.call(params)
42
+ latency = ((Time.now - start_time) * 1000).round
43
+
44
+ input_tokens = response[:prompt_eval_count] || 0
45
+ output_tokens = response[:eval_count] || 0
46
+
47
+ # v7.17.3: Track via OTEL span attributes (no track_usage call)
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
+ 'gen_ai.usage.total_tokens' => input_tokens + output_tokens,
53
+ 'agentbill.event_name' => 'ollama_local',
54
+ 'agentbill.latency_ms' => latency,
55
+ # ⚠️ Backward compatibility
56
+ 'response.prompt_eval_count' => input_tokens,
57
+ 'response.eval_count' => output_tokens,
58
+ 'latency_ms' => latency,
59
+ 'cost' => 0
60
+ })
61
+ span.set_status(0)
62
+
63
+ puts "[AgentBill] ✓ Ollama chat tracked (local, $0.00)" if config[:debug]
64
+ response
65
+ rescue => e
66
+ span.set_status(1, e.message)
67
+ raise
68
+ ensure
69
+ span.finish
70
+ # v7.17.3: Auto-flush span to OTEL collector
71
+ tracer.flush_sync rescue nil
72
+ end
73
+ end
74
+ end
75
+
76
+ def wrap_generate
77
+ original_generate = @client.method(:generate)
78
+ config = @config
79
+ tracer = @tracer
80
+
81
+ @client.define_singleton_method(:generate) do |params|
82
+ model = params[:model] || 'llama2'
83
+
84
+ start_time = Time.now
85
+ span = tracer.start_span('ollama.generate', {
86
+ # ✅ OTEL GenAI compliant attributes
87
+ 'gen_ai.system' => 'ollama',
88
+ 'gen_ai.request.model' => model,
89
+ 'gen_ai.operation.name' => 'text_completion',
90
+ # ⚠️ Backward compatibility
91
+ 'model' => model,
92
+ 'provider' => 'ollama'
93
+ })
94
+
95
+ begin
96
+ response = original_generate.call(params)
97
+ latency = ((Time.now - start_time) * 1000).round
98
+
99
+ input_tokens = response[:prompt_eval_count] || 0
100
+ output_tokens = response[:eval_count] || 0
101
+
102
+ # v7.17.3: Track via OTEL span attributes (no track_usage call)
103
+ span.set_attributes({
104
+ # ✅ OTEL GenAI compliant attributes
105
+ 'gen_ai.usage.input_tokens' => input_tokens,
106
+ 'gen_ai.usage.output_tokens' => output_tokens,
107
+ 'gen_ai.usage.total_tokens' => input_tokens + output_tokens,
108
+ 'agentbill.event_name' => 'ollama_generate',
109
+ 'agentbill.latency_ms' => latency,
110
+ # ⚠️ Backward compatibility
111
+ 'response.prompt_eval_count' => input_tokens,
112
+ 'response.eval_count' => output_tokens,
113
+ 'latency_ms' => latency,
114
+ 'cost' => 0
115
+ })
116
+ span.set_status(0)
117
+
118
+ puts "[AgentBill] ✓ Ollama generate tracked (local, $0.00)" if config[:debug]
119
+ response
120
+ rescue => e
121
+ span.set_status(1, e.message)
122
+ raise
123
+ ensure
124
+ span.finish
125
+ # v7.17.3: Auto-flush span to OTEL collector
126
+ tracer.flush_sync rescue nil
127
+ end
128
+ end
129
+ end
130
+
131
+ # v7.17.3: track_usage method DELETED - all tracking now goes through OTEL spans
132
+ end
133
+ end