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,254 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "date"
5
- require "json"
6
-
7
- require_relative "import_result"
8
- require_relative "../ledger/rollups"
9
-
10
- module LlmCostTracker
11
- module Reconciliation
12
- class Importer
13
- REQUIRED_FIELDS = %i[external_id period_start period_end].freeze
14
- FORGIVING_METADATA_SOURCES = %i[csv].to_set.freeze
15
- ENVELOPE_KEYS = %w[row_type meter authority match_basis].freeze
16
-
17
- def initialize(source:, imported_at:, provider:, window: nil, strict_metadata: nil, cursor: nil)
18
- @source = source.to_s
19
- @provider = provider.to_s
20
- @imported_at = imported_at
21
- @window = coerce_window(window)
22
- @cursor = cursor
23
- @strict_metadata = strict_metadata.nil? ? !FORGIVING_METADATA_SOURCES.include?(source.to_sym) : strict_metadata
24
- raise ArgumentError, "source must be present" if @source.empty?
25
- raise ArgumentError, "provider must be present" if @provider.empty?
26
- end
27
-
28
- def call(rows)
29
- import_record = nil
30
- ensure_reconciliation_installed!
31
- return ImportResult.empty if skippable?(rows)
32
-
33
- import_record = open_import_record
34
- result = perform_import(rows)
35
- complete_import_record(import_record, result)
36
- result.with(import_id: import_record&.id)
37
- rescue StandardError => e
38
- fail_import_record(import_record, e)
39
- raise
40
- end
41
-
42
- private
43
-
44
- attr_reader :source, :provider, :imported_at, :window, :cursor, :strict_metadata
45
-
46
- def skippable?(rows)
47
- (rows.nil? || rows.empty?) && cursor.nil?
48
- end
49
-
50
- def ensure_reconciliation_installed!
51
- return if ProviderInvoice.table_exists?
52
-
53
- raise Error,
54
- "llm_cost_tracker_provider_invoices table is missing; " \
55
- "run `rails generate llm_cost_tracker:reconciliation && rails db:migrate`"
56
- end
57
-
58
- def perform_import(rows)
59
- return ImportResult.empty if rows.nil? || rows.empty?
60
-
61
- normalized, errors = normalize_rows(rows)
62
- if normalized.empty?
63
- return ImportResult.new(inserted: 0, updated: 0, skipped: rows.size, errors: errors,
64
- import_id: nil)
65
- end
66
-
67
- existing = existing_external_ids(normalized.map { |row| row[:external_id] })
68
- rows_payload = normalized.map { |row| persistable_attributes(row) }
69
- upsert_options = { record_timestamps: true }
70
- upsert_options[:unique_by] = :external_id if ProviderInvoice.connection.supports_insert_conflict_target?
71
- ProviderInvoice.upsert_all(rows_payload, **upsert_options)
72
-
73
- inserted = normalized.count { |row| !existing.include?(row[:external_id]) }
74
- updated = normalized.size - inserted
75
- ImportResult.new(inserted: inserted, updated: updated, skipped: rows.size - normalized.size,
76
- errors: errors, import_id: nil)
77
- end
78
-
79
- def open_import_record
80
- return nil unless tracking_table_present?
81
-
82
- ProviderInvoiceImport.create!(
83
- source: source,
84
- provider: provider,
85
- cursor: cursor,
86
- window_start: window&.first,
87
- window_end: window&.last,
88
- state: ProviderInvoiceImport::STATE_RUNNING,
89
- started_at: Time.now.utc
90
- )
91
- end
92
-
93
- def complete_import_record(record, result)
94
- return unless record
95
-
96
- terminal_state = result.success? ? ProviderInvoiceImport::STATE_COMPLETED : ProviderInvoiceImport::STATE_FAILED
97
- record.update!(
98
- state: terminal_state,
99
- rows_imported: result.total_imported,
100
- finished_at: Time.now.utc,
101
- last_error: result.errors.first
102
- )
103
- end
104
-
105
- def fail_import_record(record, error)
106
- return unless record
107
-
108
- record.update!(
109
- state: ProviderInvoiceImport::STATE_FAILED,
110
- last_error: "#{error.class}: #{error.message}",
111
- finished_at: Time.now.utc
112
- )
113
- end
114
-
115
- def tracking_table_present?
116
- @tracking_table_present = ProviderInvoiceImport.table_exists? unless defined?(@tracking_table_present)
117
- @tracking_table_present
118
- end
119
-
120
- def normalize_rows(rows)
121
- errors = []
122
- normalized = rows.each_with_index.filter_map do |row, index|
123
- attrs = symbolize(row)
124
- missing = REQUIRED_FIELDS - attrs.keys
125
- if missing.any?
126
- errors << "row #{index}: missing #{missing.join(', ')}"
127
- next
128
- end
129
- period_start = parse_date(attrs[:period_start])
130
- period_end = parse_date(attrs[:period_end])
131
- next unless within_window?(period_start, period_end)
132
-
133
- attrs.merge(
134
- external_id: namespaced_external_id(attrs[:external_id]),
135
- period_start: period_start,
136
- period_end: period_end,
137
- metadata: parse_metadata(attrs[:metadata])
138
- )
139
- rescue ArgumentError => e
140
- errors << "row #{index}: #{e.message}"
141
- nil
142
- end
143
- [normalized, errors]
144
- end
145
-
146
- def within_window?(period_start, period_end)
147
- return true if window.nil?
148
-
149
- period_start <= window.last && period_end >= window.first
150
- end
151
-
152
- def coerce_window(window)
153
- return nil if window.nil?
154
- raise ArgumentError, "window must be a Range of dates" unless window.is_a?(Range)
155
-
156
- Range.new(parse_date(window.first), parse_date(window.last))
157
- end
158
-
159
- def existing_external_ids(external_ids)
160
- ProviderInvoice.where(external_id: external_ids).pluck(:external_id).to_set
161
- end
162
-
163
- def persistable_attributes(row)
164
- billed_amount = row[:billed_amount] && BigDecimal(row[:billed_amount].to_s)
165
- {
166
- source: source,
167
- external_id: row[:external_id],
168
- period_start: row[:period_start],
169
- period_end: row[:period_end],
170
- billed_amount: billed_amount,
171
- currency: (row[:currency] || Ledger::Rollups::DEFAULT_CURRENCY).to_s.upcase,
172
- metadata: stamp_metadata(row[:metadata]),
173
- imported_at: imported_at || Time.now.utc
174
- }
175
- end
176
-
177
- BASIS_DIMENSIONS_BY_PRIORITY = [
178
- %w[project provider_project_id],
179
- %w[api_key provider_api_key_id],
180
- %w[workspace provider_workspace_id],
181
- %w[model model]
182
- ].freeze
183
- private_constant :BASIS_DIMENSIONS_BY_PRIORITY
184
-
185
- def stamp_metadata(metadata)
186
- merged = metadata_with_provider(metadata)
187
- metadata_with_match_basis(merged)
188
- end
189
-
190
- def metadata_with_provider(metadata)
191
- return { "provider" => provider } if metadata.nil? || metadata.empty?
192
-
193
- existing = metadata["provider"] || metadata[:provider]
194
- return metadata if existing.is_a?(String) && !existing.empty?
195
-
196
- metadata.merge("provider" => provider)
197
- end
198
-
199
- def metadata_with_match_basis(metadata)
200
- existing = metadata["match_basis"] || metadata[:match_basis]
201
- return metadata if existing.is_a?(String) && !existing.empty?
202
-
203
- inferred = BASIS_DIMENSIONS_BY_PRIORITY.find { |_basis, key| metadata[key] || metadata[key.to_sym] }
204
- return metadata.merge("match_basis" => "period_only") if inferred.nil?
205
-
206
- metadata.merge("match_basis" => inferred.first)
207
- end
208
-
209
- def namespaced_external_id(external_id)
210
- raw = external_id.to_s
211
- scope = source == provider ? source : "#{source}/#{provider}"
212
- prefix = "#{scope}:"
213
- raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
214
- end
215
-
216
- def symbolize(row)
217
- return row if row.is_a?(Hash) && row.keys.all?(Symbol)
218
-
219
- row.to_h.transform_keys { |key| key.to_s.to_sym }
220
- end
221
-
222
- def parse_date(value)
223
- return value if value.is_a?(Date)
224
-
225
- Date.parse(value.to_s)
226
- end
227
-
228
- def parse_metadata(metadata)
229
- parsed = parse_metadata_payload(metadata)
230
- validate_envelope!(parsed) if strict_metadata
231
- parsed
232
- end
233
-
234
- def parse_metadata_payload(metadata)
235
- return {} if metadata.nil?
236
- return metadata if metadata.is_a?(Hash)
237
-
238
- JSON.parse(metadata.to_s)
239
- rescue JSON::ParserError => e
240
- raise ArgumentError, "invalid metadata JSON: #{e.message}" if strict_metadata
241
-
242
- {}
243
- end
244
-
245
- def validate_envelope!(metadata)
246
- keys = metadata.keys.map(&:to_s)
247
- missing = ENVELOPE_KEYS - keys
248
- return if missing.empty?
249
-
250
- raise ArgumentError, "metadata missing envelope keys: #{missing.join(', ')}"
251
- end
252
- end
253
- end
254
- end
@@ -1,172 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "json"
5
- require "time"
6
-
7
- require_relative "fingerprint"
8
- require_relative "../../providers/anthropic/tier_classification"
9
-
10
- module LlmCostTracker
11
- module Reconciliation
12
- module Sources
13
- module AnthropicUsage
14
- FINGERPRINT_KEYS = %i[
15
- starting_at ending_at model workspace_id
16
- service_tier context_window cost_type token_type description
17
- inference_geo
18
- ].freeze
19
- ROW_TYPE_COST = "cost"
20
- AUTHORITY_COST_API = "cost_api"
21
- DEFAULT_METER = "tokens"
22
-
23
- module_function
24
-
25
- def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
26
- payload = coerce_hash(response)
27
- buckets = Array(payload[:data])
28
- buckets.flat_map do |bucket|
29
- rows_for_bucket(bucket, authority: authority, row_type: row_type)
30
- end.compact
31
- end
32
-
33
- def rows_for_bucket(bucket, authority:, row_type:)
34
- bucket = symbolize(bucket)
35
- starting_at = bucket[:starting_at]
36
- ending_at = bucket[:ending_at]
37
- return [] unless starting_at && ending_at
38
-
39
- period_start = parse_date(starting_at)
40
- period_end = end_inclusive_date(ending_at)
41
-
42
- Array(bucket[:results]).filter_map do |raw|
43
- row_for_result(raw,
44
- period_start: period_start, period_end: period_end,
45
- starting_at: starting_at, ending_at: ending_at,
46
- authority: authority, row_type: row_type)
47
- end
48
- rescue ArgumentError
49
- []
50
- end
51
-
52
- def row_for_result(raw, period_start:, period_end:, starting_at:, ending_at:, authority:, row_type:)
53
- result = symbolize(raw)
54
- raw_amount = result[:amount]
55
- return nil if raw_amount.nil?
56
-
57
- fingerprint = fingerprint_for(result, starting_at: starting_at, ending_at: ending_at)
58
- {
59
- external_id: "cost-#{fingerprint}",
60
- period_start: period_start,
61
- period_end: period_end,
62
- billed_amount: dollars_from_cents(raw_amount),
63
- currency: (result[:currency] || "USD").to_s.upcase,
64
- metadata: metadata_for(result, authority: authority, row_type: row_type)
65
- }
66
- end
67
-
68
- def dollars_from_cents(amount)
69
- (BigDecimal(amount.to_s) / 100).to_s("F")
70
- end
71
-
72
- def metadata_for(result, authority:, row_type:)
73
- {
74
- "row_type" => row_type,
75
- "meter" => meter_for(result),
76
- "authority" => authority,
77
- "match_basis" => match_basis_for(result),
78
- "model" => result[:model],
79
- "pricing_mode" => pricing_mode_for(result),
80
- "context_window" => result[:context_window],
81
- "cost_type" => result[:cost_type],
82
- "description" => result[:description],
83
- "token_type" => result[:token_type],
84
- "inference_geo" => result[:inference_geo],
85
- "provider_workspace_id" => result[:workspace_id]
86
- }.compact
87
- end
88
-
89
- def meter_for(result)
90
- case result[:cost_type].to_s
91
- when "web_search" then "web_search"
92
- when "code_execution" then "code_execution_hour"
93
- when "session_usage" then "session_usage"
94
- when "tokens" then token_meter(result[:token_type].to_s)
95
- else DEFAULT_METER
96
- end
97
- end
98
-
99
- def token_meter(token_type)
100
- return "cache_read_input_tokens" if token_type.include?("cache_read")
101
- return "cache_creation_input_tokens" if token_type.include?("cache_creation")
102
- return "input_tokens" if token_type.include?("input")
103
- return "output_tokens" if token_type.include?("output")
104
-
105
- DEFAULT_METER
106
- end
107
-
108
- def pricing_mode_for(result)
109
- modes = []
110
- modes << "batch" if result[:service_tier].to_s.downcase == "batch"
111
- if LlmCostTracker::Providers::Anthropic::TierClassification.data_residency_geo?(result[:inference_geo])
112
- modes << "data_residency"
113
- end
114
- modes.empty? ? nil : modes.uniq.join("_")
115
- end
116
-
117
- def match_basis_for(result)
118
- return "workspace" if result[:workspace_id]
119
- return "model" if result[:model]
120
-
121
- "period_only"
122
- end
123
-
124
- def fingerprint_for(result, starting_at:, ending_at:)
125
- attributes = result.merge(starting_at: normalized_epoch(starting_at),
126
- ending_at: normalized_epoch(ending_at))
127
- Fingerprint.compute(FINGERPRINT_KEYS, attributes)
128
- end
129
-
130
- def normalized_epoch(value)
131
- return value.to_i if value.is_a?(Numeric)
132
-
133
- Time.parse(value.to_s).utc.to_i
134
- rescue ArgumentError
135
- value.to_s
136
- end
137
-
138
- def parse_date(value)
139
- return value if value.is_a?(Date)
140
- return Time.at(value).utc.to_date if value.is_a?(Numeric)
141
-
142
- Time.parse(value.to_s).utc.to_date
143
- end
144
-
145
- def end_inclusive_date(value)
146
- time = case value
147
- when Numeric then Time.at(value).utc
148
- when Date then value.to_time.utc
149
- else Time.parse(value.to_s).utc
150
- end
151
- (time - 1).utc.to_date
152
- end
153
-
154
- def coerce_hash(response)
155
- return {} if response.nil?
156
- return symbolize(response) if response.is_a?(Hash)
157
-
158
- parsed = JSON.parse(response.to_s)
159
- raise ArgumentError, "Anthropic Usage payload must be a JSON object" unless parsed.is_a?(Hash)
160
-
161
- symbolize(parsed)
162
- rescue JSON::ParserError => e
163
- raise ArgumentError, "Unable to parse Anthropic Usage payload: #{e.message}"
164
- end
165
-
166
- def symbolize(hash)
167
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
168
- end
169
- end
170
- end
171
- end
172
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module LlmCostTracker
6
- module Reconciliation
7
- module Sources
8
- module Fingerprint
9
- DIGEST_LENGTH = 16
10
-
11
- module_function
12
-
13
- def compute(keys, attributes)
14
- source_string = keys.map { |key| attributes[key].to_s }.join("|")
15
- Digest::SHA256.hexdigest(source_string)[0, DIGEST_LENGTH]
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,142 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
-
6
- require_relative "fingerprint"
7
-
8
- module LlmCostTracker
9
- module Reconciliation
10
- module Sources
11
- module OpenaiUsage
12
- FINGERPRINT_KEYS = %i[start_time end_time line_item model project_id api_key_id organization_id].freeze
13
- ROW_TYPE_COST = "cost"
14
- AUTHORITY_COST_API = "cost_api"
15
- DEFAULT_METER = "tokens"
16
-
17
- module_function
18
-
19
- def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
20
- payload = coerce_hash(response)
21
- buckets = Array(payload[:data])
22
- buckets.flat_map do |bucket|
23
- rows_for_bucket(bucket, authority: authority, row_type: row_type)
24
- end.compact
25
- end
26
-
27
- def rows_for_bucket(bucket, authority:, row_type:)
28
- bucket = symbolize(bucket)
29
- start_time = bucket[:start_time]
30
- end_time = bucket[:end_time]
31
- return [] unless start_time && end_time
32
-
33
- period_start = epoch_to_date(start_time)
34
- period_end = end_inclusive_date(end_time)
35
-
36
- Array(bucket[:results]).filter_map do |raw|
37
- row_for_result(raw,
38
- period_start: period_start, period_end: period_end,
39
- start_time: start_time, end_time: end_time,
40
- authority: authority, row_type: row_type)
41
- end
42
- rescue ArgumentError
43
- []
44
- end
45
-
46
- def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
47
- result = symbolize(raw)
48
- amount = symbolize(result[:amount] || {})
49
- billed_amount = amount[:value]
50
- return nil if billed_amount.nil?
51
-
52
- fingerprint = fingerprint_for(result, start_time: start_time, end_time: end_time)
53
- {
54
- external_id: "cost-#{fingerprint}",
55
- period_start: period_start,
56
- period_end: period_end,
57
- billed_amount: billed_amount,
58
- currency: (amount[:currency] || "USD").to_s.upcase,
59
- metadata: metadata_for(result, authority: authority, row_type: row_type)
60
- }
61
- end
62
-
63
- def metadata_for(result, authority:, row_type:)
64
- {
65
- "row_type" => row_type,
66
- "meter" => meter_for(result),
67
- "authority" => authority,
68
- "match_basis" => match_basis_for(result),
69
- "line_item" => result[:line_item],
70
- "model" => result[:model],
71
- "provider_project_id" => result[:project_id],
72
- "provider_api_key_id" => result[:api_key_id],
73
- "provider_workspace_id" => result[:organization_id]
74
- }.compact
75
- end
76
-
77
- def meter_for(result)
78
- line_item = result[:line_item].to_s.downcase
79
- case line_item
80
- when /web search/, /search content/ then "web_search"
81
- when /file search/ then "file_search_storage"
82
- when /code interpreter/, /container/ then "container_session"
83
- else DEFAULT_METER
84
- end
85
- end
86
-
87
- def match_basis_for(result)
88
- return "project" if result[:project_id]
89
- return "api_key" if result[:api_key_id]
90
- return "model" if result[:model]
91
-
92
- "period_only"
93
- end
94
-
95
- def fingerprint_for(result, start_time:, end_time:)
96
- attributes = result.merge(start_time: normalized_epoch(start_time),
97
- end_time: normalized_epoch(end_time))
98
- Fingerprint.compute(FINGERPRINT_KEYS, attributes)
99
- end
100
-
101
- def normalized_epoch(value)
102
- return value.to_i if value.is_a?(Numeric)
103
-
104
- Time.parse(value.to_s).utc.to_i
105
- rescue ArgumentError
106
- value.to_s
107
- end
108
-
109
- def epoch_to_date(value)
110
- return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
111
-
112
- Time.parse(value.to_s).utc.to_date
113
- end
114
-
115
- def end_inclusive_date(value)
116
- time = if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
117
- Time.at(Integer(value)).utc
118
- else
119
- Time.parse(value.to_s).utc
120
- end
121
- (time - 1).utc.to_date
122
- end
123
-
124
- def coerce_hash(response)
125
- return {} if response.nil?
126
- return symbolize(response) if response.is_a?(Hash)
127
-
128
- parsed = JSON.parse(response.to_s)
129
- raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)
130
-
131
- symbolize(parsed)
132
- rescue JSON::ParserError => e
133
- raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
134
- end
135
-
136
- def symbolize(hash)
137
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
138
- end
139
- end
140
- end
141
- end
142
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- require_relative "ledger/schema/provider_invoices"
6
- require_relative "ledger/schema/provider_invoice_imports"
7
- require_relative "reconciliation/import_result"
8
- require_relative "reconciliation/importer"
9
- require_relative "reconciliation/diff_result"
10
- require_relative "reconciliation/diff"
11
- require_relative "reconciliation/sources/fingerprint"
12
- require_relative "reconciliation/sources/openai_usage"
13
- require_relative "reconciliation/sources/anthropic_usage"
14
-
15
- module LlmCostTracker
16
- module Reconciliation
17
- SUPPORTED_SOURCES = %i[openai anthropic gemini csv].freeze
18
- DEFAULT_THRESHOLD_PERCENT = 5.0
19
- INVOICE_FRESHNESS_DAYS = 14
20
- SOURCE_TO_PROVIDER = {
21
- "openai" => "openai",
22
- "openai_usage" => "openai",
23
- "anthropic" => "anthropic",
24
- "anthropic_usage" => "anthropic",
25
- "gemini" => "gemini"
26
- }.freeze
27
-
28
- SCHEMA_TABLES = {
29
- Ledger::Schema::ProviderInvoices => "llm_cost_tracker_provider_invoices",
30
- Ledger::Schema::ProviderInvoiceImports => "llm_cost_tracker_provider_invoice_imports"
31
- }.freeze
32
-
33
- class << self
34
- def import(source:, rows:, provider: nil, imported_at: nil, window: nil,
35
- strict_metadata: nil, cursor: nil)
36
- ensure_enabled!
37
- ensure_source_present!(source)
38
- Importer.new(
39
- source: source,
40
- provider: resolve_provider(source: source, provider: provider),
41
- imported_at: imported_at,
42
- window: window,
43
- strict_metadata: strict_metadata,
44
- cursor: cursor
45
- ).call(rows)
46
- end
47
-
48
- def diff(source:, period_start:, period_end:, provider: nil, scope: {}, currency: nil,
49
- drilldown_limit: Diff::DEFAULT_DRILLDOWN_LIMIT)
50
- ensure_enabled!
51
- ensure_source_present!(source)
52
- Diff.new(
53
- source: source,
54
- provider: resolve_provider(source: source, provider: provider),
55
- period_start: period_start,
56
- period_end: period_end,
57
- scope: scope,
58
- currency: currency,
59
- drilldown_limit: drilldown_limit
60
- ).call
61
- end
62
-
63
- def ensure_source_present!(source)
64
- return unless source.to_s.empty?
65
-
66
- raise ArgumentError, "source must be present"
67
- end
68
-
69
- def resolve_provider(source:, provider:)
70
- return provider.to_s if provider
71
-
72
- mapped = SOURCE_TO_PROVIDER[source.to_s]
73
- return mapped if mapped
74
-
75
- recorded = recorded_provider_for(source)
76
- return recorded if recorded
77
-
78
- known = SOURCE_TO_PROVIDER.keys.join(", ")
79
- raise ArgumentError,
80
- "provider: must be specified for reconciliation source #{source.inspect}; " \
81
- "sources with a default provider mapping: #{known}"
82
- end
83
-
84
- def recorded_provider_for(source)
85
- return nil unless LlmCostTracker::ProviderInvoice.table_exists?
86
-
87
- metadata = LlmCostTracker::ProviderInvoice
88
- .where(source: source.to_s)
89
- .order(imported_at: :desc)
90
- .limit(1)
91
- .pick(:metadata)
92
- value = metadata_provider_value(metadata)
93
- value if value.is_a?(String) && !value.empty?
94
- end
95
-
96
- def metadata_provider_value(metadata)
97
- case metadata
98
- when Hash then metadata["provider"]
99
- when String
100
- parsed = JSON.parse(metadata) rescue nil # rubocop:disable Style/RescueModifier
101
- parsed.is_a?(Hash) ? parsed["provider"] : nil
102
- end
103
- end
104
-
105
- def enabled?
106
- LlmCostTracker.configuration.reconciliation_enabled
107
- end
108
-
109
- def ensure_enabled!
110
- return if enabled?
111
-
112
- raise Error,
113
- "reconciliation is disabled; set `config.reconciliation_enabled = true` in your initializer " \
114
- "(requires admin/org-level provider API keys; see docs/upgrading.md)"
115
- end
116
- end
117
- end
118
- end