llm_cost_tracker 0.11.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,249 +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 "sources/coercion"
9
- require_relative "../ledger/rollups"
10
-
11
- module LlmCostTracker
12
- module Reconciliation
13
- class Importer
14
- REQUIRED_FIELDS = %i[external_id period_start period_end].freeze
15
- FORGIVING_METADATA_SOURCES = %i[csv].to_set.freeze
16
- ENVELOPE_KEYS = %w[row_type meter authority match_basis].freeze
17
-
18
- def initialize(source:, imported_at:, provider:, window: nil, strict_metadata: nil, cursor: nil)
19
- @source = source.to_s
20
- @provider = provider.to_s
21
- @imported_at = imported_at
22
- @window = coerce_window(window)
23
- @cursor = cursor
24
- @strict_metadata = strict_metadata.nil? ? !FORGIVING_METADATA_SOURCES.include?(source.to_sym) : strict_metadata
25
- raise ArgumentError, "source must be present" if @source.empty?
26
- raise ArgumentError, "provider must be present" if @provider.empty?
27
- end
28
-
29
- def call(rows)
30
- import_record = nil
31
- ensure_reconciliation_installed!
32
- return ImportResult.empty if skippable?(rows)
33
-
34
- import_record = open_import_record
35
- result = perform_import(rows)
36
- complete_import_record(import_record, result)
37
- result.with(import_id: import_record&.id)
38
- rescue StandardError => e
39
- fail_import_record(import_record, e)
40
- raise
41
- end
42
-
43
- private
44
-
45
- attr_reader :source, :provider, :imported_at, :window, :cursor, :strict_metadata
46
-
47
- def skippable?(rows)
48
- (rows.nil? || rows.empty?) && cursor.nil?
49
- end
50
-
51
- def ensure_reconciliation_installed!
52
- return if ProviderInvoice.table_exists?
53
-
54
- raise Error,
55
- "llm_cost_tracker_provider_invoices table is missing; " \
56
- "run `rails generate llm_cost_tracker:reconciliation && rails db:migrate`"
57
- end
58
-
59
- def perform_import(rows)
60
- return ImportResult.empty if rows.nil? || rows.empty?
61
-
62
- normalized, errors = normalize_rows(rows)
63
- if normalized.empty?
64
- return ImportResult.new(inserted: 0, updated: 0, skipped: rows.size, errors: errors,
65
- import_id: nil)
66
- end
67
-
68
- existing = existing_external_ids(normalized.map { |row| row[:external_id] })
69
- rows_payload = normalized.map { |row| persistable_attributes(row) }
70
- upsert_options = { record_timestamps: true }
71
- upsert_options[:unique_by] = :external_id if ProviderInvoice.connection.supports_insert_conflict_target?
72
- ProviderInvoice.upsert_all(rows_payload, **upsert_options)
73
-
74
- inserted = normalized.count { |row| !existing.include?(row[:external_id]) }
75
- updated = normalized.size - inserted
76
- ImportResult.new(inserted: inserted, updated: updated, skipped: rows.size - normalized.size,
77
- errors: errors, import_id: nil)
78
- end
79
-
80
- def open_import_record
81
- return nil unless tracking_table_present?
82
-
83
- ProviderInvoiceImport.create!(
84
- source: source,
85
- provider: provider,
86
- cursor: cursor,
87
- window_start: window&.first,
88
- window_end: window&.last,
89
- state: ProviderInvoiceImport::STATE_RUNNING,
90
- started_at: Time.now.utc
91
- )
92
- end
93
-
94
- def complete_import_record(record, result)
95
- return unless record
96
-
97
- terminal_state = result.success? ? ProviderInvoiceImport::STATE_COMPLETED : ProviderInvoiceImport::STATE_FAILED
98
- record.update!(
99
- state: terminal_state,
100
- rows_imported: result.total_imported,
101
- finished_at: Time.now.utc,
102
- last_error: result.errors.first
103
- )
104
- end
105
-
106
- def fail_import_record(record, error)
107
- return unless record
108
-
109
- record.update!(
110
- state: ProviderInvoiceImport::STATE_FAILED,
111
- last_error: "#{error.class}: #{error.message}",
112
- finished_at: Time.now.utc
113
- )
114
- end
115
-
116
- def tracking_table_present?
117
- @tracking_table_present = ProviderInvoiceImport.table_exists? unless defined?(@tracking_table_present)
118
- @tracking_table_present
119
- end
120
-
121
- def normalize_rows(rows)
122
- errors = []
123
- normalized = rows.each_with_index.filter_map do |row, index|
124
- attrs = Sources::Coercion.symbolize(row)
125
- missing = REQUIRED_FIELDS - attrs.keys
126
- if missing.any?
127
- errors << "row #{index}: missing #{missing.join(', ')}"
128
- next
129
- end
130
- period_start = parse_date(attrs[:period_start])
131
- period_end = parse_date(attrs[:period_end])
132
- next unless within_window?(period_start, period_end)
133
-
134
- attrs.merge(
135
- external_id: namespaced_external_id(attrs[:external_id]),
136
- period_start: period_start,
137
- period_end: period_end,
138
- metadata: parse_metadata(attrs[:metadata])
139
- )
140
- rescue ArgumentError => e
141
- errors << "row #{index}: #{e.message}"
142
- nil
143
- end
144
- [normalized, errors]
145
- end
146
-
147
- def within_window?(period_start, period_end)
148
- return true if window.nil?
149
-
150
- period_start <= window.last && period_end >= window.first
151
- end
152
-
153
- def coerce_window(window)
154
- return nil if window.nil?
155
- raise ArgumentError, "window must be a Range of dates" unless window.is_a?(Range)
156
-
157
- Range.new(parse_date(window.first), parse_date(window.last))
158
- end
159
-
160
- def existing_external_ids(external_ids)
161
- ProviderInvoice.where(external_id: external_ids).pluck(:external_id).to_set
162
- end
163
-
164
- def persistable_attributes(row)
165
- billed_amount = row[:billed_amount] && BigDecimal(row[:billed_amount].to_s)
166
- {
167
- source: source,
168
- external_id: row[:external_id],
169
- period_start: row[:period_start],
170
- period_end: row[:period_end],
171
- billed_amount: billed_amount,
172
- currency: (row[:currency] || Ledger::Rollups::DEFAULT_CURRENCY).to_s.upcase,
173
- metadata: stamp_metadata(row[:metadata]),
174
- imported_at: imported_at || Time.now.utc
175
- }
176
- end
177
-
178
- BASIS_DIMENSIONS_BY_PRIORITY = [
179
- %w[project provider_project_id],
180
- %w[api_key provider_api_key_id],
181
- %w[workspace provider_workspace_id],
182
- %w[model model]
183
- ].freeze
184
- private_constant :BASIS_DIMENSIONS_BY_PRIORITY
185
-
186
- def stamp_metadata(metadata)
187
- merged = metadata_with_provider(metadata)
188
- metadata_with_match_basis(merged)
189
- end
190
-
191
- def metadata_with_provider(metadata)
192
- return { "provider" => provider } if metadata.nil? || metadata.empty?
193
-
194
- existing = metadata["provider"] || metadata[:provider]
195
- return metadata if existing.is_a?(String) && !existing.empty?
196
-
197
- metadata.merge("provider" => provider)
198
- end
199
-
200
- def metadata_with_match_basis(metadata)
201
- existing = metadata["match_basis"] || metadata[:match_basis]
202
- return metadata if existing.is_a?(String) && !existing.empty?
203
-
204
- inferred = BASIS_DIMENSIONS_BY_PRIORITY.find { |_basis, key| metadata[key] || metadata[key.to_sym] }
205
- return metadata.merge("match_basis" => "period_only") if inferred.nil?
206
-
207
- metadata.merge("match_basis" => inferred.first)
208
- end
209
-
210
- def namespaced_external_id(external_id)
211
- raw = external_id.to_s
212
- scope = source == provider ? source : "#{source}/#{provider}"
213
- prefix = "#{scope}:"
214
- raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
215
- end
216
-
217
- def parse_date(value)
218
- return value if value.is_a?(Date)
219
-
220
- Date.parse(value.to_s)
221
- end
222
-
223
- def parse_metadata(metadata)
224
- parsed = parse_metadata_payload(metadata)
225
- validate_envelope!(parsed) if strict_metadata
226
- parsed
227
- end
228
-
229
- def parse_metadata_payload(metadata)
230
- return {} if metadata.nil?
231
- return metadata if metadata.is_a?(Hash)
232
-
233
- JSON.parse(metadata.to_s)
234
- rescue JSON::ParserError => e
235
- raise ArgumentError, "invalid metadata JSON: #{e.message}" if strict_metadata
236
-
237
- {}
238
- end
239
-
240
- def validate_envelope!(metadata)
241
- keys = metadata.keys.map(&:to_s)
242
- missing = ENVELOPE_KEYS - keys
243
- return if missing.empty?
244
-
245
- raise ArgumentError, "metadata missing envelope keys: #{missing.join(', ')}"
246
- end
247
- end
248
- end
249
- end
@@ -1,148 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
- require "time"
5
-
6
- require_relative "coercion"
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 = Coercion.coerce_hash(response, label: "Anthropic Usage")
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 = Coercion.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 = Coercion.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: Coercion.normalized_epoch(starting_at),
126
- ending_at: Coercion.normalized_epoch(ending_at))
127
- Fingerprint.compute(FINGERPRINT_KEYS, attributes)
128
- end
129
-
130
- def parse_date(value)
131
- return value if value.is_a?(Date)
132
- return Time.at(value).utc.to_date if value.is_a?(Numeric)
133
-
134
- Time.parse(value.to_s).utc.to_date
135
- end
136
-
137
- def end_inclusive_date(value)
138
- time = case value
139
- when Numeric then Time.at(value).utc
140
- when Date then value.to_time.utc
141
- else Time.parse(value.to_s).utc
142
- end
143
- (time - 1).utc.to_date
144
- end
145
- end
146
- end
147
- end
148
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
-
6
- module LlmCostTracker
7
- module Reconciliation
8
- module Sources
9
- module Coercion
10
- module_function
11
-
12
- def symbolize(hash)
13
- return hash if hash.is_a?(Hash) && hash.keys.all?(Symbol)
14
-
15
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
16
- end
17
-
18
- def normalized_epoch(value)
19
- return value.to_i if value.is_a?(Numeric)
20
-
21
- Time.parse(value.to_s).utc.to_i
22
- rescue ArgumentError
23
- value.to_s
24
- end
25
-
26
- def coerce_hash(response, label:)
27
- return {} if response.nil?
28
- return symbolize(response) if response.is_a?(Hash)
29
-
30
- parsed = JSON.parse(response.to_s)
31
- raise ArgumentError, "#{label} payload must be a JSON object" unless parsed.is_a?(Hash)
32
-
33
- symbolize(parsed)
34
- rescue JSON::ParserError => e
35
- raise ArgumentError, "Unable to parse #{label} payload: #{e.message}"
36
- end
37
- end
38
- end
39
- end
40
- 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,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- require_relative "coercion"
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 = Coercion.coerce_hash(response, label: "OpenAI Costs")
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 = Coercion.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 = Coercion.symbolize(raw)
48
- amount = Coercion.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: Coercion.normalized_epoch(start_time),
97
- end_time: Coercion.normalized_epoch(end_time))
98
- Fingerprint.compute(FINGERPRINT_KEYS, attributes)
99
- end
100
-
101
- def epoch_to_date(value)
102
- return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
103
-
104
- Time.parse(value.to_s).utc.to_date
105
- end
106
-
107
- def end_inclusive_date(value)
108
- time = if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
109
- Time.at(Integer(value)).utc
110
- else
111
- Time.parse(value.to_s).utc
112
- end
113
- (time - 1).utc.to_date
114
- end
115
- end
116
- end
117
- end
118
- 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