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,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
@@ -0,0 +1,283 @@
1
+ # AgentBill Orders Resource - CRUD operations with line items
2
+ #
3
+ # IMPORTANT: At least one agent must be associated with an order via
4
+ # agent_ids or agent_external_ids. Orders cannot be created without agents.
5
+ #
6
+ # Example:
7
+ # ab = AgentBill::Client.init(api_key: 'agb_...')
8
+ #
9
+ # # List orders
10
+ # result = ab.orders.list(limit: 10)
11
+ #
12
+ # # Create order (agent_ids or agent_external_ids REQUIRED)
13
+ # order = ab.orders.create(
14
+ # account_id: 'acct-123',
15
+ # name: 'Enterprise Q1',
16
+ # agent_ids: ['agent-uuid-1'] # REQUIRED
17
+ # )
18
+ #
19
+ # # Add line item
20
+ # ab.orders.add_line_item(
21
+ # order_id: order['id'],
22
+ # description: 'AI Usage',
23
+ # quantity: 1000,
24
+ # unit_price: 0.05,
25
+ # item_type: 'usage'
26
+ # )
27
+ #
28
+ # # Get order by ID
29
+ # order = ab.orders.get('order-123')
30
+ #
31
+ # # Update order
32
+ # order = ab.orders.update('order-123', status: 'active')
33
+ #
34
+ # # Delete order
35
+ # ab.orders.delete('order-123')
36
+
37
+ require 'net/http'
38
+ require 'json'
39
+
40
+ module AgentBill
41
+ # Valid item_type values for add_line_item:
42
+ # - usage: Variable consumption billing (API calls, tokens, requests)
43
+ # - platform_fee: Platform access and service charges
44
+ # - setup_fee: One-time onboarding and implementation costs
45
+ # - base_fee: Fixed recurring monthly/annual charges
46
+ # - overage: Usage exceeding included plan amounts
47
+ # - custom: Flexible billing for unique scenarios
48
+ # - flat_rate: Fixed-price service packages or retainers
49
+ # - product: Physical or digital product sales
50
+ # - service: Professional services and consulting
51
+ # - subscription: Recurring subscription billing
52
+ VALID_ITEM_TYPES = %w[usage platform_fee setup_fee base_fee overage custom product service subscription flat_rate].freeze
53
+
54
+ class OrdersResource
55
+ def initialize(config)
56
+ @config = config
57
+ @base_url = config[:base_url] || 'https://api.agentbill.io'
58
+ @api_key = config[:api_key]
59
+ @debug = config[:debug] || false
60
+ end
61
+
62
+ # List orders with pagination and filtering
63
+ #
64
+ # @param account_id [String, nil] Filter by account ID
65
+ # @param status [String, nil] Filter by status (draft, active, completed, cancelled)
66
+ # @param limit [Integer] Number of orders to return (default: 50)
67
+ # @param offset [Integer] Offset for pagination (default: 0)
68
+ # @param search [String, nil] Search term to filter by name or external_id
69
+ # @return [Hash] Hash with 'data' (list of orders) and 'pagination' info
70
+ def list(account_id: nil, status: nil, limit: 50, offset: 0, search: nil)
71
+ uri = URI("#{@base_url}/functions/v1/api-orders")
72
+ params = { limit: limit.to_s, offset: offset.to_s }
73
+ params[:search] = search if search
74
+ params[:account_id] = account_id if account_id
75
+ params[:status] = status if status
76
+ uri.query = URI.encode_www_form(params)
77
+
78
+ response = make_request(:get, uri)
79
+ handle_response(response, 'list orders')
80
+ end
81
+
82
+ # Create a new order
83
+ #
84
+ # IMPORTANT: At least one agent must be specified via agent_ids or agent_external_ids.
85
+ # Orders cannot be created without agent association for proper billing attribution.
86
+ #
87
+ # @param account_id [String] Account (customer) UUID (required unless account_external_id provided)
88
+ # @param name [String] Order name (required)
89
+ # @param agent_ids [Array<String>, nil] List of agent UUIDs (required if agent_external_ids not provided)
90
+ # @param agent_external_ids [Array<String>, nil] List of agent external IDs (required if agent_ids not provided)
91
+ # @param external_id [String, nil] Your external ID for this order
92
+ # @param account_external_id [String, nil] External ID of the account
93
+ # @param billing_contact_id [String, nil] Contact UUID for billing
94
+ # @param billing_contact_external_id [String, nil] External ID of billing contact
95
+ # @param start_date [String, nil] Order start date (ISO format)
96
+ # @param end_date [String, nil] Order end date (ISO format)
97
+ # @param status [String, nil] Order status (draft, active, completed, cancelled)
98
+ # @param currency [String, nil] Currency code (default: USD)
99
+ # @param metadata [Hash, nil] Additional metadata
100
+ # @return [Hash] Created order object
101
+ # @raise [ArgumentError] If neither agent_ids nor agent_external_ids provided
102
+ def create(account_id:, name:, agent_ids: nil, agent_external_ids: nil, external_id: nil,
103
+ account_external_id: nil, billing_contact_id: nil, billing_contact_external_id: nil,
104
+ start_date: nil, end_date: nil, status: nil, currency: nil, metadata: nil)
105
+ # Validate agent requirement
106
+ if (agent_ids.nil? || agent_ids.empty?) && (agent_external_ids.nil? || agent_external_ids.empty?)
107
+ raise ArgumentError, 'At least one agent must be specified via agent_ids or agent_external_ids. ' \
108
+ 'Orders cannot be created without agent association for billing attribution.'
109
+ end
110
+
111
+ uri = URI("#{@base_url}/functions/v1/api-orders")
112
+
113
+ payload = { account_id: account_id, name: name }
114
+ payload[:agent_ids] = agent_ids if agent_ids&.any?
115
+ payload[:agent_external_ids] = agent_external_ids if agent_external_ids&.any?
116
+ payload[:external_id] = external_id if external_id
117
+ payload[:account_external_id] = account_external_id if account_external_id
118
+ payload[:billing_contact_id] = billing_contact_id if billing_contact_id
119
+ payload[:billing_contact_external_id] = billing_contact_external_id if billing_contact_external_id
120
+ payload[:start_date] = start_date if start_date
121
+ payload[:end_date] = end_date if end_date
122
+ payload[:status] = status if status
123
+ payload[:currency] = currency if currency
124
+ payload[:metadata] = metadata if metadata
125
+
126
+ response = make_request(:post, uri, payload)
127
+ handle_response(response, 'create order')
128
+ end
129
+
130
+ # Get an order by ID or external_id (includes line items)
131
+ #
132
+ # @param order_id [String] Order UUID or external_id
133
+ # @param by_external_id [Boolean] If true, treat order_id as external_id
134
+ # @return [Hash] Order object with order_line_items
135
+ def get(order_id, by_external_id: false)
136
+ uri = URI("#{@base_url}/functions/v1/api-orders")
137
+ params = by_external_id ? { external_id: order_id } : { id: order_id }
138
+ uri.query = URI.encode_www_form(params)
139
+
140
+ response = make_request(:get, uri)
141
+ handle_response(response, 'get order')
142
+ end
143
+
144
+ # Update an order
145
+ #
146
+ # @param order_id [String] Order UUID
147
+ # @param name [String, nil] New name
148
+ # @param billing_contact_id [String, nil] New billing contact ID
149
+ # @param start_date [String, nil] New start date
150
+ # @param end_date [String, nil] New end date
151
+ # @param status [String, nil] New status
152
+ # @param currency [String, nil] New currency
153
+ # @param metadata [Hash, nil] New metadata (replaces existing)
154
+ # @return [Hash] Updated order object
155
+ def update(order_id, name: nil, billing_contact_id: nil, start_date: nil,
156
+ end_date: nil, status: nil, currency: nil, metadata: nil)
157
+ uri = URI("#{@base_url}/functions/v1/api-orders")
158
+
159
+ payload = { id: order_id }
160
+ payload[:name] = name if name
161
+ payload[:billing_contact_id] = billing_contact_id if billing_contact_id
162
+ payload[:start_date] = start_date if start_date
163
+ payload[:end_date] = end_date if end_date
164
+ payload[:status] = status if status
165
+ payload[:currency] = currency if currency
166
+ payload[:metadata] = metadata if metadata
167
+
168
+ response = make_request(:patch, uri, payload)
169
+ handle_response(response, 'update order')
170
+ end
171
+
172
+ # Delete an order and its line items
173
+ #
174
+ # @param order_id [String] Order UUID to delete
175
+ def delete(order_id)
176
+ uri = URI("#{@base_url}/functions/v1/api-orders")
177
+ payload = { id: order_id }
178
+
179
+ response = make_request(:delete, uri, payload)
180
+ handle_response(response, 'delete order')
181
+ end
182
+
183
+ # Add a line item to an order
184
+ #
185
+ # @param order_id [String] Order UUID (required)
186
+ # @param description [String] Line item description (required)
187
+ # @param quantity [Numeric] Quantity (required)
188
+ # @param unit_price [Numeric] Price per unit (required)
189
+ # @param product_id [String, nil] Product reference ID
190
+ # @param product_name [String, nil] Product name
191
+ # @param product_sku [String, nil] Product SKU
192
+ # @param usage_metric [String, nil] Usage metric name
193
+ # @param usage_quantity [Numeric, nil] Usage quantity
194
+ # @param discount_type [String, nil] "percentage" or "fixed"
195
+ # @param discount_value [Numeric, nil] Discount amount/percentage
196
+ # @param tax_rate [Numeric, nil] Tax rate percentage
197
+ # @param notes [String, nil] Additional notes
198
+ # @param sort_order [Integer, nil] Display order
199
+ # @param agent_id [String, nil] Associated agent ID
200
+ # @param item_type [String, nil] Type: usage, platform_fee, setup_fee, base_fee, overage, custom, product, service, subscription
201
+ # @param metadata [Hash, nil] Additional metadata
202
+ # @return [Hash] Created line item object
203
+ def add_line_item(order_id:, description:, quantity:, unit_price:, product_id: nil,
204
+ product_name: nil, product_sku: nil, usage_metric: nil, usage_quantity: nil,
205
+ discount_type: nil, discount_value: nil, tax_rate: nil, notes: nil,
206
+ sort_order: nil, agent_id: nil, item_type: nil, metadata: nil)
207
+ uri = URI("#{@base_url}/functions/v1/api-orders?action=line-items")
208
+
209
+ payload = {
210
+ order_id: order_id,
211
+ description: description,
212
+ quantity: quantity,
213
+ unit_price: unit_price
214
+ }
215
+ payload[:product_id] = product_id if product_id
216
+ payload[:product_name] = product_name if product_name
217
+ payload[:product_sku] = product_sku if product_sku
218
+ payload[:usage_metric] = usage_metric if usage_metric
219
+ payload[:usage_quantity] = usage_quantity if usage_quantity
220
+ payload[:discount_type] = discount_type if discount_type
221
+ payload[:discount_value] = discount_value if discount_value
222
+ payload[:tax_rate] = tax_rate if tax_rate
223
+ payload[:notes] = notes if notes
224
+ payload[:sort_order] = sort_order if sort_order
225
+ payload[:agent_id] = agent_id if agent_id
226
+ payload[:item_type] = item_type if item_type
227
+ payload[:metadata] = metadata if metadata
228
+
229
+ response = make_request(:post, uri, payload)
230
+ handle_response(response, 'add line item')
231
+ end
232
+
233
+ # Remove a line item from an order
234
+ #
235
+ # @param line_item_id [String] Line item UUID to delete
236
+ def remove_line_item(line_item_id)
237
+ uri = URI("#{@base_url}/functions/v1/api-orders?action=line-items")
238
+ payload = { id: line_item_id }
239
+
240
+ response = make_request(:delete, uri, payload)
241
+ handle_response(response, 'remove line item')
242
+ end
243
+
244
+ private
245
+
246
+ def make_request(method, uri, payload = nil)
247
+ http = Net::HTTP.new(uri.host, uri.port)
248
+ http.use_ssl = true
249
+ http.read_timeout = 30
250
+
251
+ case method
252
+ when :get
253
+ request = Net::HTTP::Get.new(uri)
254
+ when :post
255
+ request = Net::HTTP::Post.new(uri.request_uri)
256
+ request.body = payload.to_json if payload
257
+ when :patch
258
+ request = Net::HTTP::Patch.new(uri.path)
259
+ request.body = payload.to_json if payload
260
+ when :delete
261
+ request = Net::HTTP::Delete.new(uri.request_uri)
262
+ request.body = payload.to_json if payload
263
+ end
264
+
265
+ request['Content-Type'] = 'application/json'
266
+ request['X-API-Key'] = @api_key
267
+
268
+ http.request(request)
269
+ end
270
+
271
+ def handle_response(response, operation)
272
+ body = response.body ? JSON.parse(response.body) : {}
273
+
274
+ unless %w[200 201 204].include?(response.code)
275
+ error_msg = body['error'] || "Failed to #{operation}"
276
+ raise error_msg
277
+ end
278
+
279
+ puts "[AgentBill] #{operation} successful" if @debug
280
+ body
281
+ end
282
+ end
283
+ end