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,428 @@
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 = symbolize(scope || {}).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_invoices_relation_for(:cost, fully_contained: true)
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: non_cost_invoices_total_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_invoices_relation_for(row_type_filter = nil, fully_contained: false)
92
+ relation = scoped_invoices_relation
93
+ relation = relation.where(period_start: period_start..).where(period_end: ..period_end) if fully_contained
94
+ return relation unless row_type_filter == :cost
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
+ next [] if basis == PERIOD_ONLY_BASIS
191
+
192
+ column = BASIS_DIMENSION[basis].to_s
193
+ relation = scoped_invoices_relation_for(:cost, fully_contained: true)
194
+ relation = where_match_basis_eq(relation, basis)
195
+ relation = where_metadata_present(relation, column)
196
+ values = local_index[basis].to_a
197
+ relation = where_metadata_not_in(relation, column, values) if values.any?
198
+ relation = relation.order(billed_amount: :desc)
199
+ relation = relation.limit(@drilldown_limit) if @drilldown_limit
200
+ relation.to_a.map { |invoice| build_unmatched_invoice_row(invoice, basis) }
201
+ end
202
+ rows.sort_by { |row| -BigDecimal((row[:billed_amount] || 0).to_s).abs }
203
+ end
204
+
205
+ def build_unmatched_invoice_row(invoice, basis)
206
+ {
207
+ external_id: invoice.external_id,
208
+ billed_amount: invoice.billed_amount,
209
+ attribution: invoice_attribution(invoice).compact,
210
+ match_basis: basis
211
+ }
212
+ end
213
+
214
+ def unmatched_provider_rows_total_count(local_index)
215
+ BASIS_DIMENSION.each_key.sum do |basis|
216
+ next 0 if basis == PERIOD_ONLY_BASIS
217
+
218
+ column = BASIS_DIMENSION[basis].to_s
219
+ relation = scoped_invoices_relation_for(:cost, fully_contained: true)
220
+ relation = where_match_basis_eq(relation, basis)
221
+ relation = where_metadata_present(relation, column)
222
+ values = local_index[basis].to_a
223
+ relation = where_metadata_not_in(relation, column, values) if values.any?
224
+ relation.count
225
+ end
226
+ end
227
+
228
+ def local_attribution_index_distinct
229
+ BASIS_DIMENSION.each_key.to_h do |basis|
230
+ if basis == PERIOD_ONLY_BASIS
231
+ [basis, Set.new]
232
+ else
233
+ column = BASIS_DIMENSION[basis]
234
+ values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
235
+ [basis, Set.new(values)]
236
+ end
237
+ end
238
+ end
239
+
240
+ def unmatched_local_calls_in(invoice_basis_values)
241
+ grouped = scoped_line_items_with_attribution.each_with_object({}) do |row, totals|
242
+ attribution = row[:attribution].compact
243
+ next if attribution.empty?
244
+ next if local_call_matched?(attribution, invoice_basis_values)
245
+
246
+ totals[attribution] ||= { count: 0, total_cost: BigDecimal("0") }
247
+ totals[attribution][:count] += 1
248
+ totals[attribution][:total_cost] += BigDecimal(row[:total_cost].to_s)
249
+ end
250
+ grouped.map { |attribution, summary| summary.merge(attribution: attribution) }
251
+ end
252
+
253
+ def unmatched_local_calls_total_count(invoice_basis_values)
254
+ unmatched = 0
255
+ scoped_calls_relation.find_each(batch_size: 1_000) do |call|
256
+ attribution = ATTRIBUTION_KEYS.each_with_object({}) do |key, acc|
257
+ value = call.public_send(key)
258
+ acc[key] = value unless value.nil? || value.to_s.empty?
259
+ end
260
+ next if attribution.empty?
261
+ next if local_call_matched?(attribution, invoice_basis_values)
262
+
263
+ unmatched += 1
264
+ end
265
+ unmatched
266
+ end
267
+
268
+ def invoice_basis_values_distinct_sql
269
+ BASIS_DIMENSION.each_key.to_h do |basis|
270
+ if basis == PERIOD_ONLY_BASIS
271
+ [basis, Set.new]
272
+ else
273
+ column = BASIS_DIMENSION[basis].to_s
274
+ relation = scoped_invoices_relation_for(:cost, fully_contained: true)
275
+ relation = where_match_basis_eq(relation, basis)
276
+ relation = where_metadata_present(relation, column)
277
+ values = pluck_metadata_distinct(relation, column)
278
+ [basis, Set.new(values)]
279
+ end
280
+ end
281
+ end
282
+
283
+ def non_cost_invoices_total_count
284
+ scoped_non_cost_invoices_relation.count
285
+ end
286
+
287
+ def scoped_non_cost_invoices_relation
288
+ connection = ProviderInvoice.connection
289
+ if Ledger::Schema::Adapter.postgresql?(connection)
290
+ scoped_invoices_relation.where(
291
+ "metadata->>'row_type' IS NOT NULL AND metadata->>'row_type' <> ?", COST_ROW_TYPE
292
+ )
293
+ else
294
+ scoped_invoices_relation.where(
295
+ "JSON_EXTRACT(metadata, '$.row_type') IS NOT NULL AND " \
296
+ "JSON_TYPE(JSON_EXTRACT(metadata, '$.row_type')) <> 'NULL' AND " \
297
+ "JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.row_type')) <> ?", COST_ROW_TYPE
298
+ )
299
+ end
300
+ end
301
+
302
+ def scoped_calls_relation
303
+ line_items_table = LlmCostTracker::CallLineItem.quoted_table_name
304
+ relation = LlmCostTracker::Call
305
+ .where(provider: provider)
306
+ .where(tracked_at: window_start...window_end)
307
+ .where(
308
+ "EXISTS (SELECT 1 FROM #{line_items_table} " \
309
+ "WHERE #{line_items_table}.llm_cost_tracker_call_id = #{calls_table}.id " \
310
+ "AND #{line_items_table}.currency = ?)",
311
+ currency
312
+ )
313
+ scope.each { |key, value| relation = relation.where(key => value) }
314
+ relation
315
+ end
316
+
317
+ def scoped_line_items_with_attribution
318
+ attribution_columns = ATTRIBUTION_KEYS.map { |key| "#{calls_table}.#{key}" }
319
+ call_id = "#{calls_table}.id"
320
+ rows = scoped_line_items
321
+ .group(call_id, *attribution_columns)
322
+ .pluck(call_id, Arel.sql("SUM(cost)"), *attribution_columns)
323
+ rows.map do |row|
324
+ _id, total_cost, *attrs = row
325
+ { total_cost: total_cost, attribution: ATTRIBUTION_KEYS.zip(attrs).to_h }
326
+ end
327
+ end
328
+
329
+ def where_match_basis_eq(relation, basis)
330
+ connection = ProviderInvoice.connection
331
+ if Ledger::Schema::Adapter.postgresql?(connection)
332
+ relation.where("metadata->>'match_basis' = ?", basis)
333
+ else
334
+ relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.match_basis')) = ?", basis)
335
+ end
336
+ end
337
+
338
+ def where_metadata_present(relation, column)
339
+ connection = ProviderInvoice.connection
340
+ if Ledger::Schema::Adapter.postgresql?(connection)
341
+ relation.where("metadata->>? IS NOT NULL", column)
342
+ else
343
+ relation.where("JSON_EXTRACT(metadata, ?) IS NOT NULL", "$.#{column}")
344
+ end
345
+ end
346
+
347
+ def where_metadata_not_in(relation, column, values)
348
+ connection = ProviderInvoice.connection
349
+ if Ledger::Schema::Adapter.postgresql?(connection)
350
+ relation.where.not("metadata->>? IN (?)", column, values)
351
+ else
352
+ relation.where.not("JSON_UNQUOTE(JSON_EXTRACT(metadata, ?)) IN (?)", "$.#{column}", values)
353
+ end
354
+ end
355
+
356
+ def pluck_metadata_distinct(relation, column)
357
+ connection = ProviderInvoice.connection
358
+ expr =
359
+ if Ledger::Schema::Adapter.postgresql?(connection)
360
+ Arel.sql("metadata->>'#{column}'")
361
+ else
362
+ Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.#{column}'))")
363
+ end
364
+ relation.distinct.pluck(expr).compact
365
+ end
366
+
367
+ def invoice_match_basis(invoice)
368
+ declared = invoice.metadata["match_basis"]
369
+ return declared if BASIS_DIMENSION.key?(declared)
370
+ return declared if declared == PERIOD_ONLY_BASIS
371
+
372
+ BASIS_DIMENSION.each do |basis, dimension|
373
+ return basis if invoice.metadata[dimension.to_s]
374
+ end
375
+ PERIOD_ONLY_BASIS
376
+ end
377
+
378
+ def local_call_matched?(attribution, basis_values)
379
+ BASIS_DIMENSION.any? do |basis, local_key|
380
+ value = attribution[local_key]
381
+ value && basis_values[basis].include?(value)
382
+ end
383
+ end
384
+
385
+ def non_cost_invoices_to_rows(invoices)
386
+ invoices.map do |invoice|
387
+ {
388
+ external_id: invoice.external_id,
389
+ row_type: invoice.metadata["row_type"],
390
+ meter: invoice.metadata["meter"],
391
+ billed_amount: invoice.billed_amount,
392
+ attribution: invoice_attribution(invoice).compact,
393
+ match_basis: invoice_match_basis(invoice)
394
+ }
395
+ end
396
+ end
397
+
398
+ def invoice_attribution(invoice)
399
+ ATTRIBUTION_KEYS.to_h { |key| [key, invoice.metadata[key.to_s]] }
400
+ end
401
+
402
+ def window_start
403
+ Time.utc(period_start.year, period_start.month, period_start.day)
404
+ end
405
+
406
+ def window_end
407
+ next_day = period_end + 1
408
+ Time.utc(next_day.year, next_day.month, next_day.day)
409
+ end
410
+
411
+ def percent_for(local, provider)
412
+ return nil if provider.zero?
413
+
414
+ ((local - provider) * 100 / provider).round(4).to_f
415
+ end
416
+
417
+ def symbolize(hash)
418
+ hash.to_h.transform_keys { |key| key.to_s.to_sym }
419
+ end
420
+
421
+ def parse_date(value)
422
+ return value if value.is_a?(Date)
423
+
424
+ Date.parse(value.to_s)
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,48 @@
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
+
43
+ def empty?
44
+ provider_total.zero? && local_total.zero?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
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