llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -0,0 +1,188 @@
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: image_input
72
+ kind: image_token
73
+ direction: input
74
+ modality: image
75
+ cache_state: none
76
+ unit: token
77
+ category: token
78
+ token_key: image_input_tokens
79
+ cost_key: image_input_cost
80
+
81
+ - key: image_output
82
+ kind: image_token
83
+ direction: output
84
+ modality: image
85
+ cache_state: none
86
+ unit: token
87
+ category: token
88
+ token_key: image_output_tokens
89
+ cost_key: image_output_cost
90
+
91
+ - key: web_search_request
92
+ kind: web_search_request
93
+ direction: neither
94
+ modality: text
95
+ cache_state: none
96
+ unit: request
97
+ category: tool
98
+ rate_basis: per_1k_requests
99
+
100
+ - key: web_search_preview_request_reasoning
101
+ kind: web_search_preview_request_reasoning
102
+ direction: neither
103
+ modality: text
104
+ cache_state: none
105
+ unit: request
106
+ category: tool
107
+ rate_basis: per_1k_requests
108
+
109
+ - key: web_search_preview_request_non_reasoning
110
+ kind: web_search_preview_request_non_reasoning
111
+ direction: neither
112
+ modality: text
113
+ cache_state: none
114
+ unit: request
115
+ category: tool
116
+ rate_basis: per_1k_requests
117
+
118
+ - key: web_fetch_request
119
+ kind: web_fetch_request
120
+ direction: neither
121
+ modality: text
122
+ cache_state: none
123
+ unit: request
124
+ category: tool
125
+ rate_basis: per_1k_requests
126
+
127
+ - key: file_search_call
128
+ kind: file_search_call
129
+ direction: neither
130
+ modality: text
131
+ cache_state: none
132
+ unit: request
133
+ category: tool
134
+ rate_basis: per_1k_requests
135
+
136
+ - key: container_session
137
+ kind: container_session
138
+ direction: neither
139
+ modality: none
140
+ cache_state: none
141
+ unit: session
142
+ category: runtime
143
+ rate_basis: per_session
144
+
145
+ - key: code_execution_request
146
+ kind: code_execution_request
147
+ direction: neither
148
+ modality: none
149
+ cache_state: none
150
+ unit: request
151
+ category: runtime
152
+ rate_basis: per_1k_requests
153
+
154
+ - key: code_execution_hour
155
+ kind: code_execution_hour
156
+ direction: neither
157
+ modality: none
158
+ cache_state: none
159
+ unit: hour
160
+ category: runtime
161
+ rate_basis: per_hour
162
+
163
+ - key: grounding_request
164
+ kind: grounding_request
165
+ direction: neither
166
+ modality: text
167
+ cache_state: none
168
+ unit: request
169
+ category: tool
170
+ rate_basis: per_1k_requests
171
+
172
+ - key: text_to_speech_character
173
+ kind: text_to_speech_character
174
+ direction: output
175
+ modality: audio
176
+ cache_state: none
177
+ unit: character
178
+ category: tool
179
+ rate_basis: per_million_characters
180
+
181
+ - key: mcp_call
182
+ kind: mcp_call
183
+ direction: neither
184
+ modality: text
185
+ cache_state: none
186
+ unit: request
187
+ category: tool
188
+ rate_basis: per_request
@@ -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.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,25 @@ 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)
22
+ next unless total >= budget
20
23
 
21
- handle_exceeded(budget_type: period, total: total, budget: budget) if total >= budget
24
+ raise BudgetExceededError.new(**budget_payload(
25
+ budget_type: budget_type, total: total, budget: budget, last_event: nil
26
+ ))
22
27
  end
23
28
  end
24
29
 
@@ -27,13 +32,13 @@ module LlmCostTracker
27
32
  return unless event.total_cost
28
33
 
29
34
  check_per_call_budget(event, config)
30
- budgets = check_period_budgets(config)
31
- totals = totals_for_check(event, budgets)
35
+ budgets = { daily: config.daily_budget, monthly: config.monthly_budget }.compact
36
+ totals = totals_for(budgets.keys, time: event.tracked_at)
32
37
 
33
- budgets.each do |period, budget|
34
- total = totals.fetch(period)
38
+ budgets.each do |budget_type, budget|
39
+ total = totals.fetch(budget_type)
35
40
 
36
- handle_exceeded(budget_type: period, total: total, budget: budget, last_event: event) if total >= budget
41
+ handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
37
42
  end
38
43
  end
39
44
 
@@ -43,30 +48,20 @@ module LlmCostTracker
43
48
  budget = config.per_call_budget
44
49
  return unless budget
45
50
 
46
- call_cost = event.total_cost
47
- return unless call_cost >= budget
51
+ total = event.total_cost
52
+ return unless total >= budget
48
53
 
49
- handle_exceeded(budget_type: :per_call, total: call_cost, budget: budget, last_event: event)
54
+ handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
50
55
  end
51
56
 
52
- def enforce_period_budgets(config)
53
- {
54
- monthly: config.monthly_budget,
55
- daily: config.daily_budget
56
- }.compact
57
- end
57
+ def totals_for(budget_types, time:)
58
+ return {} if budget_types.empty?
58
59
 
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)
60
+ periods = budget_types.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
61
+ period_totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
62
+ BUDGET_TYPE_TO_PERIOD.each_with_object({}) do |(budget_type, period), totals|
63
+ totals[budget_type] = period_totals[period] if period_totals.key?(period)
64
+ end
70
65
  end
71
66
 
72
67
  def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
@@ -85,21 +80,16 @@ module LlmCostTracker
85
80
  end
86
81
 
87
82
  def budget_payload(budget_type:, total:, budget:, last_event:)
88
- payload = {
83
+ {
89
84
  budget_type: budget_type,
90
85
  total: total,
91
86
  budget: budget,
92
87
  last_event: last_event
93
88
  }
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
89
  end
99
90
 
100
91
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
101
92
  return false unless config.on_budget_exceeded
102
- return true unless config.budget_exceeded_behavior == :notify
103
93
  return true unless last_event&.total_cost
104
94
  return true if budget_type == :per_call
105
95