llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,409 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "date"
5
-
6
- require_relative "diff_result"
7
- require_relative "../ledger/rollups"
8
-
9
- module LlmCostTracker
10
- module Reconciliation
11
- class Diff # rubocop:disable Metrics/ClassLength
12
- SCOPE_KEYS = %i[provider_project_id provider_api_key_id provider_workspace_id].freeze
13
- ATTRIBUTION_KEYS = (SCOPE_KEYS + [:model]).freeze
14
- COST_ROW_TYPE = "cost"
15
- PERIOD_ONLY_BASIS = "period_only"
16
- BASIS_DIMENSION = {
17
- "project" => :provider_project_id,
18
- "api_key" => :provider_api_key_id,
19
- "workspace" => :provider_workspace_id,
20
- "model" => :model
21
- }.freeze
22
-
23
- DEFAULT_DRILLDOWN_LIMIT = 100
24
-
25
- def initialize(source:, period_start:, period_end:, provider:, scope: {}, currency: nil,
26
- drilldown_limit: DEFAULT_DRILLDOWN_LIMIT)
27
- @source = source.to_s
28
- @provider = provider.to_s
29
- @period_start = parse_date(period_start)
30
- @period_end = parse_date(period_end)
31
- @scope = (scope || {}).to_h.transform_keys { |key| key.to_s.to_sym }.slice(*SCOPE_KEYS)
32
- @currency = (currency || Ledger::Rollups::DEFAULT_CURRENCY).to_s.upcase
33
- @drilldown_limit = drilldown_limit
34
- raise ArgumentError, "source must be present" if @source.empty?
35
- raise ArgumentError, "provider must be present" if @provider.empty?
36
- raise ArgumentError, "period_end must be on or after period_start" if @period_end < @period_start
37
- end
38
-
39
- def call
40
- provider_total = scoped_cost_invoices_in_window
41
- .sum(:billed_amount)
42
- .then { |sum| BigDecimal(sum.to_s) }
43
- local_index = local_attribution_index_distinct
44
- invoice_basis_values = invoice_basis_values_distinct_sql
45
-
46
- local_total, local_total_source = sum_local_total
47
-
48
- unmatched_providers = unmatched_provider_rows_from_sql(local_index)
49
- unmatched_locals = unmatched_local_calls_in(invoice_basis_values)
50
- non_cost_rows = non_cost_invoices_to_rows(scoped_non_cost_invoices_for_drilldown)
51
-
52
- DiffResult.new(
53
- source: source,
54
- provider: provider,
55
- period_start: period_start,
56
- period_end: period_end,
57
- currency: currency,
58
- scope: scope,
59
- provider_total: provider_total,
60
- local_total: local_total,
61
- local_total_source: local_total_source,
62
- delta_amount: local_total - provider_total,
63
- delta_percent: percent_for(local_total, provider_total),
64
- unmatched_provider_rows: cap_by_amount(unmatched_providers, :billed_amount),
65
- unmatched_provider_rows_total: unmatched_provider_rows_total_count(local_index),
66
- unmatched_local_calls: cap_by_amount(unmatched_locals, :total_cost),
67
- unmatched_local_calls_total: unmatched_local_calls_total_count(invoice_basis_values),
68
- non_cost_rows: cap_by_amount(non_cost_rows, :billed_amount),
69
- non_cost_rows_total: scoped_non_cost_invoices_relation.count
70
- )
71
- end
72
-
73
- private
74
-
75
- attr_reader :source, :provider, :period_start, :period_end, :scope, :currency
76
-
77
- def cap_by_amount(rows, key)
78
- return rows if @drilldown_limit.nil? || rows.size <= @drilldown_limit
79
-
80
- rows
81
- .sort_by { |row| -BigDecimal((row[key] || 0).to_s).abs }
82
- .first(@drilldown_limit)
83
- end
84
-
85
- def scoped_non_cost_invoices_for_drilldown
86
- relation = scoped_non_cost_invoices_relation.order(Arel.sql("ABS(billed_amount) DESC"))
87
- relation = relation.limit(@drilldown_limit) if @drilldown_limit
88
- relation.to_a
89
- end
90
-
91
- def scoped_cost_invoices_in_window
92
- relation = scoped_invoices_relation
93
- .where(period_start: period_start..)
94
- .where(period_end: ..period_end)
95
-
96
- connection = ProviderInvoice.connection
97
- if Ledger::Schema::Adapter.postgresql?(connection)
98
- relation.where(
99
- "metadata->>'row_type' IS NULL OR metadata->>'row_type' = ?", COST_ROW_TYPE
100
- )
101
- else
102
- relation.where(
103
- "JSON_EXTRACT(metadata, '$.row_type') IS NULL OR " \
104
- "JSON_TYPE(JSON_EXTRACT(metadata, '$.row_type')) = 'NULL' OR " \
105
- "JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.row_type')) = ?", COST_ROW_TYPE
106
- )
107
- end
108
- end
109
-
110
- def scoped_invoices_relation
111
- relation = ProviderInvoice
112
- .where(source: source, currency: currency)
113
- .where(period_start: ..period_end)
114
- .where(period_end: period_start..)
115
- relation = apply_metadata_scope(relation, "provider" => @provider)
116
- scope.empty? ? relation : apply_metadata_scope(relation, scope.transform_keys(&:to_s))
117
- end
118
-
119
- def apply_metadata_scope(relation, criteria)
120
- connection = ProviderInvoice.connection
121
- if Ledger::Schema::Adapter.postgresql?(connection)
122
- relation.where("metadata @> ?::jsonb", criteria.to_json)
123
- else
124
- criteria.inject(relation) do |chain, (key, value)|
125
- chain.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, ?)) = ?", "$.#{key}", value.to_s)
126
- end
127
- end
128
- end
129
-
130
- def cost_row?(invoice)
131
- row_type = invoice.metadata["row_type"]
132
- row_type.nil? || row_type.to_s == COST_ROW_TYPE
133
- end
134
-
135
- def fully_contained?(invoice)
136
- invoice.period_start >= period_start && invoice.period_end <= period_end
137
- end
138
-
139
- def sum_local_total
140
- return line_items_total unless rollup_fast_path?
141
-
142
- rollup = rollup_total
143
- return [rollup, :rollups] if rollup.positive?
144
-
145
- line_items_total
146
- end
147
-
148
- def line_items_total
149
- [BigDecimal(scoped_line_items.sum(:cost).to_s), :line_items]
150
- end
151
-
152
- def rollup_fast_path?
153
- scope.empty? && month_aligned_period? &&
154
- LlmCostTracker.configuration.cache_rollups && LlmCostTracker::CallRollup.table_exists?
155
- end
156
-
157
- def month_aligned_period?
158
- period_start.day == 1 && (period_end + 1).day == 1 && period_end >= period_start
159
- end
160
-
161
- def rollup_total
162
- cursor = period_start
163
- buckets = []
164
- while cursor <= period_end
165
- buckets << cursor
166
- cursor = cursor.next_month
167
- end
168
- relation = LlmCostTracker::CallRollup
169
- .where(period: "month", currency: currency, provider: provider)
170
- .where(period_start: buckets)
171
- BigDecimal(relation.sum(:total_cost).to_s)
172
- end
173
-
174
- def scoped_line_items
175
- relation = LlmCostTracker::CallLineItem
176
- .joins(:call)
177
- .where(llm_cost_tracker_call_line_items: { currency: currency })
178
- .where("#{calls_table}.tracked_at" => window_start...window_end)
179
- .where("#{calls_table}.provider" => provider)
180
- scope.each { |key, value| relation = relation.where("#{calls_table}.#{key}" => value) }
181
- relation
182
- end
183
-
184
- def calls_table
185
- LlmCostTracker::Call.quoted_table_name
186
- end
187
-
188
- def unmatched_provider_rows_from_sql(local_index)
189
- rows = BASIS_DIMENSION.each_key.flat_map do |basis|
190
- column = BASIS_DIMENSION[basis].to_s
191
- relation = scoped_cost_invoices_in_window
192
- relation = where_match_basis_eq(relation, basis)
193
- relation = where_metadata_present(relation, column)
194
- values = local_index[basis].to_a
195
- relation = where_metadata_not_in(relation, column, values) if values.any?
196
- relation = relation.order(billed_amount: :desc)
197
- relation = relation.limit(@drilldown_limit) if @drilldown_limit
198
- relation.to_a.map { |invoice| build_unmatched_invoice_row(invoice, basis) }
199
- end
200
- rows.sort_by { |row| -BigDecimal((row[:billed_amount] || 0).to_s).abs }
201
- end
202
-
203
- def build_unmatched_invoice_row(invoice, basis)
204
- {
205
- external_id: invoice.external_id,
206
- billed_amount: invoice.billed_amount,
207
- attribution: invoice_attribution(invoice).compact,
208
- match_basis: basis
209
- }
210
- end
211
-
212
- def unmatched_provider_rows_total_count(local_index)
213
- BASIS_DIMENSION.each_key.sum do |basis|
214
- column = BASIS_DIMENSION[basis].to_s
215
- relation = scoped_cost_invoices_in_window
216
- relation = where_match_basis_eq(relation, basis)
217
- relation = where_metadata_present(relation, column)
218
- values = local_index[basis].to_a
219
- relation = where_metadata_not_in(relation, column, values) if values.any?
220
- relation.count
221
- end
222
- end
223
-
224
- def local_attribution_index_distinct
225
- BASIS_DIMENSION.each_key.to_h do |basis|
226
- column = BASIS_DIMENSION[basis]
227
- values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
228
- [basis, Set.new(values)]
229
- end
230
- end
231
-
232
- def unmatched_local_calls_in(invoice_basis_values)
233
- grouped = scoped_line_items_with_attribution.each_with_object({}) do |row, totals|
234
- attribution = row[:attribution].compact
235
- next if attribution.empty?
236
- next if local_call_matched?(attribution, invoice_basis_values)
237
-
238
- totals[attribution] ||= { count: 0, total_cost: BigDecimal("0") }
239
- totals[attribution][:count] += 1
240
- totals[attribution][:total_cost] += BigDecimal(row[:total_cost].to_s)
241
- end
242
- grouped.map { |attribution, summary| summary.merge(attribution: attribution) }
243
- end
244
-
245
- def unmatched_local_calls_total_count(invoice_basis_values)
246
- unmatched = 0
247
- scoped_calls_relation.in_batches(of: 1_000) do |batch|
248
- batch.pluck(*ATTRIBUTION_KEYS).each do |row|
249
- attribution = ATTRIBUTION_KEYS.zip(row).each_with_object({}) do |(key, value), acc|
250
- acc[key] = value unless value.nil? || value.to_s.empty?
251
- end
252
- next if attribution.empty?
253
- next if local_call_matched?(attribution, invoice_basis_values)
254
-
255
- unmatched += 1
256
- end
257
- end
258
- unmatched
259
- end
260
-
261
- def invoice_basis_values_distinct_sql
262
- BASIS_DIMENSION.each_key.to_h do |basis|
263
- column = BASIS_DIMENSION[basis].to_s
264
- relation = scoped_cost_invoices_in_window
265
- relation = where_match_basis_eq(relation, basis)
266
- relation = where_metadata_present(relation, column)
267
- values = pluck_metadata_distinct(relation, column)
268
- [basis, Set.new(values)]
269
- end
270
- end
271
-
272
- def scoped_non_cost_invoices_relation
273
- connection = ProviderInvoice.connection
274
- if Ledger::Schema::Adapter.postgresql?(connection)
275
- scoped_invoices_relation.where(
276
- "metadata->>'row_type' IS NOT NULL AND metadata->>'row_type' <> ?", COST_ROW_TYPE
277
- )
278
- else
279
- scoped_invoices_relation.where(
280
- "JSON_EXTRACT(metadata, '$.row_type') IS NOT NULL AND " \
281
- "JSON_TYPE(JSON_EXTRACT(metadata, '$.row_type')) <> 'NULL' AND " \
282
- "JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.row_type')) <> ?", COST_ROW_TYPE
283
- )
284
- end
285
- end
286
-
287
- def scoped_calls_relation
288
- line_items_table = LlmCostTracker::CallLineItem.quoted_table_name
289
- relation = LlmCostTracker::Call
290
- .where(provider: provider)
291
- .where(tracked_at: window_start...window_end)
292
- .where(
293
- "EXISTS (SELECT 1 FROM #{line_items_table} " \
294
- "WHERE #{line_items_table}.llm_cost_tracker_call_id = #{calls_table}.id " \
295
- "AND #{line_items_table}.currency = ?)",
296
- currency
297
- )
298
- scope.each { |key, value| relation = relation.where(key => value) }
299
- relation
300
- end
301
-
302
- def scoped_line_items_with_attribution
303
- attribution_columns = ATTRIBUTION_KEYS.map { |key| "#{calls_table}.#{key}" }
304
- call_id = "#{calls_table}.id"
305
- rows = scoped_line_items
306
- .group(call_id, *attribution_columns)
307
- .pluck(call_id, Arel.sql("SUM(cost)"), *attribution_columns)
308
- rows.map do |row|
309
- _id, total_cost, *attrs = row
310
- { total_cost: total_cost, attribution: ATTRIBUTION_KEYS.zip(attrs).to_h }
311
- end
312
- end
313
-
314
- def where_match_basis_eq(relation, basis)
315
- connection = ProviderInvoice.connection
316
- if Ledger::Schema::Adapter.postgresql?(connection)
317
- relation.where("metadata->>'match_basis' = ?", basis)
318
- else
319
- relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.match_basis')) = ?", basis)
320
- end
321
- end
322
-
323
- def where_metadata_present(relation, column)
324
- connection = ProviderInvoice.connection
325
- if Ledger::Schema::Adapter.postgresql?(connection)
326
- relation.where("metadata->>? IS NOT NULL", column)
327
- else
328
- relation.where("JSON_EXTRACT(metadata, ?) IS NOT NULL", "$.#{column}")
329
- end
330
- end
331
-
332
- def where_metadata_not_in(relation, column, values)
333
- connection = ProviderInvoice.connection
334
- if Ledger::Schema::Adapter.postgresql?(connection)
335
- relation.where.not("metadata->>? IN (?)", column, values)
336
- else
337
- relation.where.not("JSON_UNQUOTE(JSON_EXTRACT(metadata, ?)) IN (?)", "$.#{column}", values)
338
- end
339
- end
340
-
341
- def pluck_metadata_distinct(relation, column)
342
- connection = ProviderInvoice.connection
343
- expr =
344
- if Ledger::Schema::Adapter.postgresql?(connection)
345
- Arel.sql("metadata->>'#{column}'")
346
- else
347
- Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.#{column}'))")
348
- end
349
- relation.distinct.pluck(expr).compact
350
- end
351
-
352
- def invoice_match_basis(invoice)
353
- declared = invoice.metadata["match_basis"]
354
- return declared if BASIS_DIMENSION.key?(declared)
355
- return declared if declared == PERIOD_ONLY_BASIS
356
-
357
- BASIS_DIMENSION.each do |basis, dimension|
358
- return basis if invoice.metadata[dimension.to_s]
359
- end
360
- PERIOD_ONLY_BASIS
361
- end
362
-
363
- def local_call_matched?(attribution, basis_values)
364
- BASIS_DIMENSION.any? do |basis, local_key|
365
- value = attribution[local_key]
366
- value && basis_values[basis].include?(value)
367
- end
368
- end
369
-
370
- def non_cost_invoices_to_rows(invoices)
371
- invoices.map do |invoice|
372
- {
373
- external_id: invoice.external_id,
374
- row_type: invoice.metadata["row_type"],
375
- meter: invoice.metadata["meter"],
376
- billed_amount: invoice.billed_amount,
377
- attribution: invoice_attribution(invoice).compact,
378
- match_basis: invoice_match_basis(invoice)
379
- }
380
- end
381
- end
382
-
383
- def invoice_attribution(invoice)
384
- ATTRIBUTION_KEYS.to_h { |key| [key, invoice.metadata[key.to_s]] }
385
- end
386
-
387
- def window_start
388
- Time.utc(period_start.year, period_start.month, period_start.day)
389
- end
390
-
391
- def window_end
392
- next_day = period_end + 1
393
- Time.utc(next_day.year, next_day.month, next_day.day)
394
- end
395
-
396
- def percent_for(local, provider)
397
- return nil if provider.zero?
398
-
399
- ((local - provider) * 100 / provider).round(4).to_f
400
- end
401
-
402
- def parse_date(value)
403
- return value if value.is_a?(Date)
404
-
405
- Date.parse(value.to_s)
406
- end
407
- end
408
- end
409
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Reconciliation
5
- DiffResult = Data.define(
6
- :source,
7
- :provider,
8
- :period_start,
9
- :period_end,
10
- :currency,
11
- :scope,
12
- :provider_total,
13
- :local_total,
14
- :local_total_source,
15
- :delta_amount,
16
- :delta_percent,
17
- :unmatched_provider_rows,
18
- :unmatched_provider_rows_total,
19
- :unmatched_local_calls,
20
- :unmatched_local_calls_total,
21
- :non_cost_rows,
22
- :non_cost_rows_total
23
- ) do
24
- def unmatched_provider_rows_truncated?
25
- unmatched_provider_rows.size < unmatched_provider_rows_total
26
- end
27
-
28
- def unmatched_local_calls_truncated?
29
- unmatched_local_calls.size < unmatched_local_calls_total
30
- end
31
-
32
- def non_cost_rows_truncated?
33
- non_cost_rows.size < non_cost_rows_total
34
- end
35
-
36
- def aligned?(threshold_percent: Reconciliation::DEFAULT_THRESHOLD_PERCENT)
37
- return true if provider_total.zero? && local_total.zero?
38
- return false if delta_percent.nil?
39
-
40
- delta_percent.abs <= threshold_percent
41
- end
42
- end
43
- end
44
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Reconciliation
5
- ImportResult = Data.define(:inserted, :updated, :skipped, :errors, :import_id) do
6
- def self.empty
7
- new(inserted: 0, updated: 0, skipped: 0, errors: [], import_id: nil)
8
- end
9
-
10
- def total_imported
11
- inserted + updated
12
- end
13
-
14
- def success?
15
- errors.empty?
16
- end
17
- end
18
- end
19
- end