llm_cost_tracker 0.7.2 → 0.8.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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ require_relative "../errors"
6
+
7
+ module LlmCostTracker
8
+ module Billing
9
+ module Components
10
+ Component = Data.define(
11
+ :key,
12
+ :kind,
13
+ :direction,
14
+ :modality,
15
+ :cache_state,
16
+ :unit,
17
+ :category,
18
+ :token_key,
19
+ :cost_key
20
+ )
21
+
22
+ REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
23
+ DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
24
+
25
+ def self.load_registry
26
+ Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
27
+ .map { |attributes| build(attributes) }
28
+ .freeze
29
+ end
30
+
31
+ def self.build(attributes)
32
+ missing = REQUIRED_FIELDS - attributes.keys
33
+ raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
34
+
35
+ Component.new(
36
+ key: attributes.fetch(:key).to_sym,
37
+ kind: attributes.fetch(:kind).to_sym,
38
+ direction: attributes.fetch(:direction).to_sym,
39
+ modality: attributes.fetch(:modality).to_sym,
40
+ cache_state: attributes.fetch(:cache_state).to_sym,
41
+ unit: attributes.fetch(:unit).to_sym,
42
+ category: attributes.fetch(:category).to_sym,
43
+ token_key: attributes[:token_key]&.to_sym,
44
+ cost_key: attributes[:cost_key]&.to_sym
45
+ )
46
+ end
47
+
48
+ REGISTRY = load_registry
49
+ BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
50
+ TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,117 @@
1
+ - key: input
2
+ kind: text_token
3
+ direction: input
4
+ modality: text
5
+ cache_state: none
6
+ unit: token
7
+ category: token
8
+ token_key: input_tokens
9
+ cost_key: input_cost
10
+
11
+ - key: cache_read_input
12
+ kind: text_token
13
+ direction: input
14
+ modality: text
15
+ cache_state: read
16
+ unit: token
17
+ category: token
18
+ token_key: cache_read_input_tokens
19
+ cost_key: cache_read_input_cost
20
+
21
+ - key: cache_write_input
22
+ kind: text_token
23
+ direction: input
24
+ modality: text
25
+ cache_state: write_default
26
+ unit: token
27
+ category: token
28
+ token_key: cache_write_input_tokens
29
+ cost_key: cache_write_input_cost
30
+
31
+ - key: cache_write_extended_input
32
+ kind: text_token
33
+ direction: input
34
+ modality: text
35
+ cache_state: write_extended
36
+ unit: token
37
+ category: token
38
+ token_key: cache_write_extended_input_tokens
39
+ cost_key: cache_write_extended_input_cost
40
+
41
+ - key: output
42
+ kind: text_token
43
+ direction: output
44
+ modality: text
45
+ cache_state: none
46
+ unit: token
47
+ category: token
48
+ token_key: output_tokens
49
+ cost_key: output_cost
50
+
51
+ - key: audio_input
52
+ kind: audio_token
53
+ direction: input
54
+ modality: audio
55
+ cache_state: none
56
+ unit: token
57
+ category: token
58
+ token_key: audio_input_tokens
59
+ cost_key: audio_input_cost
60
+
61
+ - key: audio_output
62
+ kind: audio_token
63
+ direction: output
64
+ modality: audio
65
+ cache_state: none
66
+ unit: token
67
+ category: token
68
+ token_key: audio_output_tokens
69
+ cost_key: audio_output_cost
70
+
71
+ - key: web_search_request
72
+ kind: web_search_request
73
+ direction: neither
74
+ modality: text
75
+ cache_state: none
76
+ unit: request
77
+ category: tool
78
+
79
+ - key: file_search_call
80
+ kind: file_search_call
81
+ direction: neither
82
+ modality: text
83
+ cache_state: none
84
+ unit: request
85
+ category: tool
86
+
87
+ - key: container_session
88
+ kind: container_session
89
+ direction: neither
90
+ modality: none
91
+ cache_state: none
92
+ unit: session
93
+ category: runtime
94
+
95
+ - key: code_execution_request
96
+ kind: code_execution_request
97
+ direction: neither
98
+ modality: none
99
+ cache_state: none
100
+ unit: request
101
+ category: runtime
102
+
103
+ - key: code_execution_hour
104
+ kind: code_execution_hour
105
+ direction: neither
106
+ modality: none
107
+ cache_state: none
108
+ unit: hour
109
+ category: runtime
110
+
111
+ - key: grounding_request
112
+ kind: grounding_request
113
+ direction: neither
114
+ modality: text
115
+ cache_state: none
116
+ unit: request
117
+ category: tool
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "components"
4
+
5
+ module LlmCostTracker
6
+ module Billing
7
+ module CostStatus
8
+ COMPLETE = "complete"
9
+ FREE = "free"
10
+ PARTIAL = "partial"
11
+ UNKNOWN = "unknown"
12
+
13
+ class << self
14
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
15
+ def call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
16
+ token_pricing_partial: false)
17
+ return UNKNOWN if usage_source == :unknown
18
+
19
+ token_billable = Components::TOKEN_PRICED.any? do |component|
20
+ token_usage.public_send(component.token_key).positive?
21
+ end
22
+ service_billable = false
23
+ service_priced = false
24
+ service_unpriced = false
25
+ service_line_items.each do |line_item|
26
+ next unless line_item.billable?
27
+
28
+ service_billable = true
29
+ service_priced ||= line_item.priced?
30
+ service_unpriced ||= line_item.unpriced?
31
+ break if service_priced && service_unpriced
32
+ end
33
+
34
+ priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
35
+ unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
36
+ return UNKNOWN if unpriced && !priced
37
+ return PARTIAL if unpriced
38
+
39
+ total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
40
+ end
41
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "components"
6
+ require_relative "cost_status"
7
+
8
+ module LlmCostTracker
9
+ module Billing
10
+ LineItem = Data.define(
11
+ :kind,
12
+ :direction,
13
+ :modality,
14
+ :cache_state,
15
+ :quantity,
16
+ :unit,
17
+ :rate_amount,
18
+ :rate_quantity,
19
+ :cost,
20
+ :currency,
21
+ :cost_status,
22
+ :pricing_basis,
23
+ :price_key,
24
+ :price_source,
25
+ :price_source_version,
26
+ :provider_field,
27
+ :provider_item_id,
28
+ :details
29
+ )
30
+
31
+ class LineItem
32
+ USD = "USD"
33
+ OPTIONAL_ATTRIBUTES = %i[
34
+ pricing_basis
35
+ price_key
36
+ price_source
37
+ price_source_version
38
+ provider_field
39
+ provider_item_id
40
+ ].freeze
41
+ SYMBOL_ATTRIBUTES = %i[
42
+ kind
43
+ direction
44
+ modality
45
+ cache_state
46
+ unit
47
+ pricing_basis
48
+ price_source
49
+ ].freeze
50
+
51
+ def self.build(attributes)
52
+ attributes = attributes.to_h
53
+ component = component_for(attributes)
54
+ normalized = {
55
+ kind: symbol_or_nil(attributes[:kind]) || component&.kind,
56
+ direction: symbol_or_nil(attributes[:direction]) || component&.direction,
57
+ modality: symbol_or_nil(attributes[:modality]) || component&.modality,
58
+ cache_state: symbol_or_nil(attributes[:cache_state]) || component&.cache_state,
59
+ quantity: decimal_or_zero(attributes[:quantity]),
60
+ unit: symbol_or_nil(attributes[:unit]) || component&.unit,
61
+ rate_amount: decimal_or_nil(attributes[:rate_amount]),
62
+ rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
63
+ cost: decimal_or_nil(attributes[:cost]),
64
+ currency: attributes[:currency] || USD,
65
+ cost_status: cost_status_for(attributes),
66
+ details: attributes[:details] || {}
67
+ }.merge(optional_attributes_for(attributes))
68
+
69
+ new(**normalized)
70
+ end
71
+
72
+ def self.from_token_usage(token_usage)
73
+ return [] unless token_usage
74
+
75
+ Components::TOKEN_PRICED.filter_map do |component|
76
+ quantity = token_usage.public_send(component.token_key)
77
+ next unless quantity.positive?
78
+
79
+ new(
80
+ kind: component.kind,
81
+ direction: component.direction,
82
+ modality: component.modality,
83
+ cache_state: component.cache_state,
84
+ quantity: BigDecimal(quantity.to_s),
85
+ unit: component.unit,
86
+ rate_amount: nil,
87
+ rate_quantity: BigDecimal("1"),
88
+ cost: nil,
89
+ currency: USD,
90
+ cost_status: CostStatus::UNKNOWN,
91
+ pricing_basis: nil,
92
+ price_key: nil,
93
+ price_source: nil,
94
+ price_source_version: nil,
95
+ provider_field: nil,
96
+ provider_item_id: nil,
97
+ details: {}
98
+ )
99
+ end
100
+ end
101
+
102
+ def self.cost_status_for(attributes)
103
+ explicit = attributes[:cost_status]
104
+ return explicit.to_s if explicit
105
+
106
+ cost = decimal_or_nil(attributes[:cost])
107
+ return CostStatus::UNKNOWN if cost.nil?
108
+
109
+ cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE
110
+ end
111
+
112
+ def self.component_for(attributes)
113
+ component_key = attributes[:component_key] || attributes[:price_key]
114
+ return nil unless component_key
115
+
116
+ Components::BY_KEY[component_key.to_sym]
117
+ end
118
+
119
+ def self.symbol_or_nil(value)
120
+ return nil if value.nil?
121
+
122
+ value.is_a?(Symbol) ? value : value.to_s.to_sym
123
+ end
124
+
125
+ def self.decimal_or_nil(value)
126
+ return nil if value.nil? || value == ""
127
+
128
+ BigDecimal(value.to_s)
129
+ end
130
+
131
+ def self.decimal_or_zero(value)
132
+ decimal_or_nil(value) || BigDecimal("0")
133
+ end
134
+
135
+ def self.optional_attributes_for(attributes)
136
+ OPTIONAL_ATTRIBUTES.to_h do |key|
137
+ value = attributes[key]
138
+ value = value.to_sym if value.is_a?(String) && SYMBOL_ATTRIBUTES.include?(key)
139
+ [key, value]
140
+ end
141
+ end
142
+
143
+ private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero,
144
+ :optional_attributes_for
145
+
146
+ def billable?
147
+ quantity.positive?
148
+ end
149
+
150
+ def priced?
151
+ [CostStatus::COMPLETE, CostStatus::FREE].include?(cost_status)
152
+ end
153
+
154
+ def unpriced?
155
+ cost_status == CostStatus::UNKNOWN
156
+ end
157
+
158
+ def token?
159
+ unit == :token
160
+ end
161
+
162
+ def cost_value
163
+ cost || BigDecimal("0")
164
+ end
165
+
166
+ def apply_rate(rate)
167
+ rate_amount = rate.fetch(:amount)
168
+ rate_quantity = rate.fetch(:quantity)
169
+ applied_cost = (quantity / rate_quantity) * rate_amount
170
+ with(
171
+ rate_amount: rate_amount,
172
+ rate_quantity: rate_quantity,
173
+ cost: applied_cost,
174
+ currency: rate.fetch(:currency),
175
+ cost_status: applied_cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE,
176
+ price_key: rate.fetch(:source_key),
177
+ price_source: rate.fetch(:source),
178
+ price_source_version: rate.fetch(:source_version)
179
+ )
180
+ end
181
+
182
+ def to_h
183
+ super.transform_values do |value|
184
+ value.is_a?(BigDecimal) ? value.to_s("F") : value
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -5,20 +5,22 @@ require_relative "ledger"
5
5
 
6
6
  module LlmCostTracker
7
7
  class Budget
8
+ BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
9
+
8
10
  class << self
9
11
  def enforce!
10
12
  config = LlmCostTracker.configuration
11
13
  return unless config.budget_exceeded_behavior == :block_requests
12
14
 
13
- budgets = enforce_period_budgets(config)
15
+ budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
14
16
  return if budgets.empty?
15
17
 
16
- totals = LlmCostTracker::Ledger::Period::Totals.call(budgets.keys, time: Time.now.utc)
18
+ totals = totals_for(budgets.keys, time: Time.now.utc)
17
19
 
18
- budgets.each do |period, budget|
19
- total = totals.fetch(period)
20
+ budgets.each do |budget_type, budget|
21
+ total = totals.fetch(budget_type)
20
22
 
21
- handle_exceeded(budget_type: period, total: total, budget: budget) if total >= budget
23
+ handle_exceeded(budget_type: budget_type, total: total, budget: budget) if total >= budget
22
24
  end
23
25
  end
24
26
 
@@ -27,13 +29,13 @@ module LlmCostTracker
27
29
  return unless event.total_cost
28
30
 
29
31
  check_per_call_budget(event, config)
30
- budgets = check_period_budgets(config)
31
- totals = totals_for_check(event, budgets)
32
+ budgets = { daily: config.daily_budget, monthly: config.monthly_budget }.compact
33
+ totals = totals_for(budgets.keys, time: event.tracked_at)
32
34
 
33
- budgets.each do |period, budget|
34
- total = totals.fetch(period)
35
+ budgets.each do |budget_type, budget|
36
+ total = totals.fetch(budget_type)
35
37
 
36
- handle_exceeded(budget_type: period, total: total, budget: budget, last_event: event) if total >= budget
38
+ handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
37
39
  end
38
40
  end
39
41
 
@@ -43,30 +45,20 @@ module LlmCostTracker
43
45
  budget = config.per_call_budget
44
46
  return unless budget
45
47
 
46
- call_cost = event.total_cost
47
- return unless call_cost >= budget
48
+ total = event.total_cost
49
+ return unless total >= budget
48
50
 
49
- handle_exceeded(budget_type: :per_call, total: call_cost, budget: budget, last_event: event)
51
+ handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
50
52
  end
51
53
 
52
- def enforce_period_budgets(config)
53
- {
54
- monthly: config.monthly_budget,
55
- daily: config.daily_budget
56
- }.compact
57
- end
54
+ def totals_for(budget_types, time:)
55
+ return {} if budget_types.empty?
58
56
 
59
- def check_period_budgets(config)
60
- {
61
- daily: config.daily_budget,
62
- monthly: config.monthly_budget
63
- }.compact
64
- end
65
-
66
- def totals_for_check(event, budgets)
67
- return {} if budgets.empty?
68
-
69
- LlmCostTracker::Ledger::Period::Totals.call(budgets.keys, time: event.tracked_at)
57
+ periods = budget_types.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
58
+ period_totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
59
+ BUDGET_TYPE_TO_PERIOD.each_with_object({}) do |(budget_type, period), totals|
60
+ totals[budget_type] = period_totals[period] if period_totals.key?(period)
61
+ end
70
62
  end
71
63
 
72
64
  def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
@@ -85,16 +77,12 @@ module LlmCostTracker
85
77
  end
86
78
 
87
79
  def budget_payload(budget_type:, total:, budget:, last_event:)
88
- payload = {
80
+ {
89
81
  budget_type: budget_type,
90
82
  total: total,
91
83
  budget: budget,
92
84
  last_event: last_event
93
85
  }
94
- payload[:monthly_total] = total if budget_type == :monthly
95
- payload[:daily_total] = total if budget_type == :daily
96
- payload[:call_cost] = total if budget_type == :per_call
97
- payload
98
86
  end
99
87
 
100
88
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
@@ -2,20 +2,27 @@
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
4
  require "active_support/core_ext/object/deep_dup"
5
+ require "json"
5
6
 
6
7
  require_relative "stream"
8
+ require_relative "../timing"
7
9
 
8
10
  module LlmCostTracker
9
11
  module Capture
10
12
  class StreamCollector
11
13
  attr_reader :provider
12
14
 
13
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {},
14
- context_tags: nil)
15
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
16
+ provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
17
+ metadata: {}, context_tags: nil)
15
18
  @provider = provider.to_s
16
19
  @model = model
17
20
  @latency_ms = latency_ms
18
21
  @provider_response_id = provider_response_id
22
+ @provider_project_id = provider_project_id
23
+ @provider_api_key_id = provider_api_key_id
24
+ @provider_workspace_id = provider_workspace_id
25
+ @batch = batch
19
26
  @pricing_mode = pricing_mode
20
27
  @metadata = (metadata || {}).deep_dup
21
28
  @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
@@ -23,7 +30,7 @@ module LlmCostTracker
23
30
  @captured_bytes = 0
24
31
  @overflowed = false
25
32
  @explicit_usage = nil
26
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ @started_at = LlmCostTracker::Timing.now_monotonic
27
34
  @finished = false
28
35
  @mutex = Mutex.new
29
36
  end
@@ -66,10 +73,16 @@ module LlmCostTracker
66
73
  @mutex.synchronize do
67
74
  ensure_open!
68
75
  @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
69
- @explicit_usage = TokenUsage.from_hash(extra.merge(
70
- input_tokens: input_tokens.to_i,
71
- output_tokens: output_tokens.to_i
72
- ))
76
+ @provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
77
+ @provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
78
+ @provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
79
+ batch = extra.delete(:batch)
80
+ @batch = batch unless batch.nil?
81
+ @explicit_usage = TokenUsage.build(
82
+ **extra.slice(*TokenUsage.members),
83
+ input_tokens: input_tokens,
84
+ output_tokens: output_tokens
85
+ )
73
86
  end
74
87
  self
75
88
  end
@@ -79,6 +92,7 @@ module LlmCostTracker
79
92
  return if @finished
80
93
 
81
94
  @finished = true
95
+ pricing_mode = Pricing.normalize_mode(@pricing_mode)
82
96
  {
83
97
  events: @events.dup,
84
98
  overflowed: @overflowed,
@@ -86,7 +100,8 @@ module LlmCostTracker
86
100
  model: @model,
87
101
  latency_ms: @latency_ms,
88
102
  provider_response_id: @provider_response_id,
89
- pricing_mode: @pricing_mode,
103
+ capture_dimensions: capture_dimensions(pricing_mode),
104
+ pricing_mode: pricing_mode,
90
105
  metadata: @metadata.deep_dup,
91
106
  context_tags: @context_tags.deep_dup
92
107
  }
@@ -98,8 +113,7 @@ module LlmCostTracker
98
113
 
99
114
  Tracker.record(
100
115
  capture: capture,
101
- latency_ms: snapshot[:latency_ms] ||
102
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
116
+ latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
103
117
  pricing_mode: snapshot[:pricing_mode],
104
118
  metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
105
119
  context_tags: snapshot[:context_tags]
@@ -108,6 +122,16 @@ module LlmCostTracker
108
122
 
109
123
  private
110
124
 
125
+ def capture_dimensions(pricing_mode)
126
+ batch = @batch.nil? ? UsageCapture.batch_from_pricing_mode?(pricing_mode).presence : @batch
127
+ {
128
+ provider_project_id: @provider_project_id.to_s.strip.presence,
129
+ provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
130
+ provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
131
+ batch: batch
132
+ }.compact
133
+ end
134
+
111
135
  def ensure_open!
112
136
  return unless @finished
113
137
 
@@ -116,15 +140,14 @@ module LlmCostTracker
116
140
 
117
141
  def build_usage_capture(snapshot)
118
142
  return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
119
- return build_unknown_usage(snapshot) if snapshot[:overflowed]
120
143
 
121
144
  capture = Parsers.find_for_provider(@provider)&.parse_stream(
122
145
  response_status: 200,
123
146
  events: snapshot[:events]
124
147
  )
125
- if capture
148
+ if capture && (capture.usage_source != :unknown || !snapshot[:overflowed])
126
149
  model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
127
- return capture.with(provider: @provider, model: model)
150
+ return capture.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
128
151
  end
129
152
 
130
153
  build_unknown_usage(snapshot)
@@ -145,7 +168,9 @@ module LlmCostTracker
145
168
  model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
146
169
  token_usage: snapshot[:explicit_usage],
147
170
  stream: true,
148
- usage_source: :manual
171
+ usage_source: :manual,
172
+ pricing_mode: snapshot[:pricing_mode],
173
+ **snapshot.fetch(:capture_dimensions)
149
174
  )
150
175
  end
151
176
 
@@ -155,34 +180,23 @@ module LlmCostTracker
155
180
  model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
156
181
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
157
182
  stream: true,
158
- usage_source: :unknown
183
+ usage_source: :unknown,
184
+ pricing_mode: snapshot[:pricing_mode],
185
+ **snapshot.fetch(:capture_dimensions)
159
186
  )
160
187
  end
161
188
 
162
189
  def capture_event(data, type:)
163
- size = type.to_s.bytesize + estimated_bytes(data) + 32
190
+ event = { event: type, data: data }
191
+ size = JSON.generate(event).bytesize
164
192
  if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
165
- @events << { event: type, data: data.deep_dup }
193
+ @events << event.deep_dup
166
194
  @captured_bytes += size
167
195
  else
168
196
  @overflowed = true
169
- @events.clear
170
- end
171
- end
172
-
173
- def estimated_bytes(value)
174
- case value
175
- when Hash
176
- value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
177
- when Array
178
- value.sum { |nested| estimated_bytes(nested) + 2 }
179
- when String
180
- value.bytesize + 2
181
- when Numeric, true, false, nil
182
- value.to_s.bytesize
183
- else
184
- value.to_s.bytesize + 2
185
197
  end
198
+ rescue JSON::JSONError, TypeError
199
+ @overflowed = true
186
200
  end
187
201
  end
188
202
  end