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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ledger
5
+ module Schema
6
+ module CallTags
7
+ REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
8
+
9
+ REQUIRED_INDEX_COLUMNS = [
10
+ %w[key value],
11
+ %w[llm_cost_tracker_call_id]
12
+ ].freeze
13
+
14
+ class << self
15
+ def current_schema_errors
16
+ connection = LlmCostTracker::Call.connection
17
+ Ledger::Schema::Adapter.ensure_supported!(connection)
18
+ table_name = LlmCostTracker::CallTag.table_name
19
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
20
+
21
+ columns = LlmCostTracker::CallTag.columns_hash
22
+ errors = []
23
+ missing = REQUIRED_COLUMNS - columns.keys
24
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
25
+ errors.concat(missing_index_errors(connection, table_name))
26
+ errors
27
+ end
28
+
29
+ def missing_index_errors(connection, table_name)
30
+ existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
31
+ REQUIRED_INDEX_COLUMNS.filter_map do |required|
32
+ next if existing.any? { |columns| (required - columns).empty? }
33
+
34
+ "missing index on (#{required.join(', ')})"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -15,23 +15,37 @@ module LlmCostTracker
15
15
  total_tokens
16
16
  cache_read_input_tokens
17
17
  cache_write_input_tokens
18
- cache_write_1h_input_tokens
18
+ cache_write_extended_input_tokens
19
+ audio_input_tokens
20
+ audio_output_tokens
21
+ image_input_tokens
22
+ image_output_tokens
19
23
  hidden_output_tokens
20
- input_cost
21
- output_cost
22
24
  total_cost
23
- cache_read_input_cost
24
- cache_write_input_cost
25
- cache_write_1h_input_cost
26
25
  latency_ms
27
26
  stream
28
27
  usage_source
29
28
  provider_response_id
29
+ provider_project_id
30
+ provider_api_key_id
31
+ provider_workspace_id
32
+ batch
30
33
  pricing_mode
31
- tags
34
+ cost_status
35
+ pricing_snapshot
32
36
  tracked_at
33
37
  ].freeze
34
38
 
39
+ REQUIRED_INDEXES = [
40
+ { columns: :event_id, unique: true },
41
+ { columns: :tracked_at },
42
+ { columns: %i[provider tracked_at] },
43
+ { columns: %i[model tracked_at] },
44
+ { columns: :cost_status },
45
+ { columns: :provider_response_id }
46
+ ].freeze
47
+ private_constant :REQUIRED_INDEXES
48
+
35
49
  class << self
36
50
  def current_schema?
37
51
  current_schema_errors.empty?
@@ -48,8 +62,8 @@ module LlmCostTracker
48
62
  private
49
63
 
50
64
  def schema_capabilities
51
- columns = Ledger::Call.columns_hash
52
- adapter_name = Ledger::Call.connection.adapter_name
65
+ columns = LlmCostTracker::Call.columns_hash
66
+ adapter_name = LlmCostTracker::Call.connection.adapter_name
53
67
  cache = @schema_capabilities
54
68
 
55
69
  return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
@@ -73,22 +87,21 @@ module LlmCostTracker
73
87
  errors = []
74
88
  missing = missing_columns_for(columns)
75
89
  errors << "missing columns: #{missing.join(', ')}" if missing.any?
90
+ errors.concat(Adapter.json_column_errors(columns["pricing_snapshot"], adapter_name, "pricing_snapshot"))
91
+ errors.concat(missing_index_errors)
92
+ errors
93
+ end
76
94
 
77
- tag_column = columns["tags"]
78
- if tag_column
79
- postgresql = Ledger::Schema::Adapter.postgresql?(adapter_name)
80
- expected_type = postgresql ? "jsonb" : "json"
81
- valid_type =
82
- if postgresql
83
- tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb"
84
- else
85
- tag_column.type == :json
86
- end
87
-
88
- errors << "tags column must use #{expected_type}" unless valid_type
89
- end
95
+ def missing_index_errors
96
+ connection = LlmCostTracker::Call.connection
97
+ REQUIRED_INDEXES.filter_map do |spec|
98
+ next if connection.index_exists?(LlmCostTracker::Call.table_name, spec[:columns], **spec.except(:columns))
90
99
 
91
- errors
100
+ prefix = spec[:unique] ? "unique " : ""
101
+ "missing #{prefix}index: #{Array(spec[:columns]).join(', ')}"
102
+ end
103
+ rescue StandardError
104
+ []
92
105
  end
93
106
 
94
107
  def missing_columns_for(columns)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module IngestionInboxEntries
9
+ REQUIRED_COLUMNS = %w[
10
+ event_id
11
+ total_cost
12
+ tracked_at
13
+ payload
14
+ locked_at
15
+ locked_by
16
+ attempts
17
+ last_error
18
+ created_at
19
+ updated_at
20
+ ].freeze
21
+
22
+ UNIQUE_COLUMNS = %i[event_id].freeze
23
+
24
+ class << self
25
+ def current_schema_errors
26
+ connection = LlmCostTracker::Ingestion::InboxEntry.connection
27
+ Adapter.ensure_supported!(connection)
28
+ table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
29
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
30
+
31
+ errors = []
32
+ missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::InboxEntry.columns_hash.keys
33
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
34
+ errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
35
+ errors
36
+ end
37
+
38
+ private
39
+
40
+ def event_id_unique_index?(connection, table_name)
41
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module IngestionLeases
9
+ REQUIRED_COLUMNS = %w[
10
+ name
11
+ locked_by
12
+ locked_until
13
+ created_at
14
+ updated_at
15
+ ].freeze
16
+
17
+ UNIQUE_COLUMNS = %i[name].freeze
18
+
19
+ class << self
20
+ def current_schema_errors
21
+ connection = LlmCostTracker::Ingestion::Lease.connection
22
+ Adapter.ensure_supported!(connection)
23
+ table_name = LlmCostTracker::Ingestion::Lease.table_name
24
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
25
+
26
+ errors = []
27
+ missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::Lease.columns_hash.keys
28
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
29
+ errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
30
+ errors
31
+ end
32
+
33
+ private
34
+
35
+ def name_unique_index?(connection, table_name)
36
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module ProviderInvoiceImports
9
+ REQUIRED_COLUMNS = %w[
10
+ source cursor window_start window_end state last_error
11
+ rows_imported started_at finished_at
12
+ ].freeze
13
+ SOURCE_STARTED_AT_INDEX = %i[source started_at].freeze
14
+
15
+ class << self
16
+ def current_schema_errors
17
+ connection = LlmCostTracker::Call.connection
18
+ Adapter.ensure_supported!(connection)
19
+ table_name = LlmCostTracker::ProviderInvoiceImport.table_name
20
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
+
22
+ errors = []
23
+ errors.concat(column_errors)
24
+ errors.concat(index_errors(connection, table_name))
25
+ errors
26
+ end
27
+
28
+ private
29
+
30
+ def column_errors
31
+ missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoiceImport.columns_hash.keys
32
+ return [] if missing.empty?
33
+
34
+ ["missing columns: #{missing.join(', ')}"]
35
+ end
36
+
37
+ def index_errors(connection, table_name)
38
+ return [] if connection.index_exists?(table_name, SOURCE_STARTED_AT_INDEX)
39
+
40
+ ["missing index: source, started_at"]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module ProviderInvoices
9
+ REQUIRED_COLUMNS = %w[
10
+ source period_start period_end external_id billed_amount currency metadata imported_at
11
+ ].freeze
12
+ UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
13
+ SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
14
+
15
+ class << self
16
+ def current_schema_errors
17
+ connection = LlmCostTracker::Call.connection
18
+ Adapter.ensure_supported!(connection)
19
+ table_name = LlmCostTracker::ProviderInvoice.table_name
20
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
+
22
+ errors = []
23
+ errors.concat(column_errors)
24
+ errors.concat(metadata_type_errors(connection))
25
+ errors.concat(index_errors(connection, table_name))
26
+ errors
27
+ end
28
+
29
+ private
30
+
31
+ def column_errors
32
+ missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoice.columns_hash.keys
33
+ return [] if missing.empty?
34
+
35
+ ["missing columns: #{missing.join(', ')}"]
36
+ end
37
+
38
+ def metadata_type_errors(connection)
39
+ metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
40
+ Adapter.json_column_errors(metadata, connection, "metadata")
41
+ end
42
+
43
+ def index_errors(connection, table_name)
44
+ errors = []
45
+ unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
46
+ errors << "missing unique index: external_id"
47
+ end
48
+ unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
49
+ errors << "missing index: source, currency, period_start"
50
+ end
51
+ errors
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  require_relative "../pricing"
6
+ require_relative "../billing/line_item"
4
7
  require_relative "rollups"
8
+ require_relative "tags/encoding"
5
9
 
6
10
  module LlmCostTracker
7
11
  module Ledger
@@ -14,9 +18,14 @@ module LlmCostTracker
14
18
  insertable = insertable_events(events)
15
19
 
16
20
  if insertable.any?
17
- rows = insertable.map { |event| attributes_for(event) }
18
- Ledger::Call.insert_all!(rows, record_timestamps: true, returning: false)
19
- Ledger::Rollups.increment_many!(insertable)
21
+ LlmCostTracker::Call.transaction do
22
+ rows = insertable.map { |event| attributes_for(event) }
23
+ LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
24
+ call_ids = call_ids_for(insertable)
25
+ insert_line_items(insertable, call_ids)
26
+ insert_call_tags(insertable, call_ids)
27
+ end
28
+ increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
20
29
  end
21
30
  events
22
31
  end
@@ -28,40 +37,114 @@ module LlmCostTracker
28
37
  event_id: event.event_id,
29
38
  provider: event.provider,
30
39
  model: event.model,
31
- tags: stored_tags(event.tags),
32
40
  tracked_at: event.tracked_at,
33
- pricing_mode: event.pricing_mode,
41
+ pricing_mode: event.pricing_mode&.name,
34
42
  latency_ms: event.latency_ms,
35
43
  stream: event.stream,
36
- usage_source: event.usage_source,
37
- provider_response_id: event.provider_response_id
44
+ usage_source: event.usage_source&.name,
45
+ provider_response_id: event.provider_response_id,
46
+ provider_project_id: event.provider_project_id,
47
+ provider_api_key_id: event.provider_api_key_id,
48
+ provider_workspace_id: event.provider_workspace_id,
49
+ batch: event.batch,
50
+ cost_status: event.cost_status,
51
+ pricing_snapshot: event.pricing_snapshot
38
52
  }
39
53
 
40
54
  attributes
41
- .merge(event.token_usage.stored_attributes)
55
+ .merge(event.token_usage.to_h)
42
56
  .merge(Pricing.stored_cost_attributes(event.cost || {}))
43
57
  end
44
58
 
45
- def insertable_events(events)
46
- existing_ids = Ledger::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
47
- seen_ids = Set.new
59
+ def call_ids_for(events)
60
+ LlmCostTracker::Call
61
+ .where(event_id: events.map(&:event_id))
62
+ .pluck(:event_id, :id)
63
+ .to_h
64
+ end
48
65
 
49
- events.select do |event|
50
- event_id = event.event_id
51
- !existing_ids.include?(event_id) && seen_ids.add?(event_id)
66
+ def insert_line_items(events, call_ids)
67
+ rows = events.flat_map do |event|
68
+ (event.line_items || []).each_with_index.map do |line_item, position|
69
+ line_item_attributes(
70
+ call_id: call_ids.fetch(event.event_id),
71
+ line_item: line_item,
72
+ position: position
73
+ )
74
+ end
52
75
  end
76
+ return if rows.empty?
77
+
78
+ LlmCostTracker::CallLineItem.insert_all!(rows, record_timestamps: false, returning: false)
53
79
  end
54
80
 
55
- def stored_tags(tags)
56
- (tags || {}).transform_keys(&:to_s).transform_values { |value| stored_tag_value(value) }
81
+ def line_item_attributes(call_id:, line_item:, position:)
82
+ {
83
+ llm_cost_tracker_call_id: call_id,
84
+ position: position,
85
+ kind: line_item.kind&.to_s,
86
+ direction: line_item.direction&.to_s,
87
+ modality: line_item.modality&.to_s,
88
+ cache_state: line_item.cache_state&.to_s || "none",
89
+ quantity: line_item.quantity,
90
+ unit: line_item.unit&.to_s,
91
+ rate_amount: line_item.rate_amount,
92
+ rate_quantity: line_item.rate_quantity,
93
+ cost: line_item.cost,
94
+ currency: line_item.currency,
95
+ cost_status: line_item.cost_status,
96
+ pricing_basis: line_item.pricing_basis&.to_s,
97
+ price_key: line_item.price_key,
98
+ price_source: line_item.price_source&.to_s,
99
+ price_source_version: line_item.price_source_version,
100
+ provider_field: line_item.provider_field,
101
+ provider_item_id: line_item.provider_item_id,
102
+ details: stored_details(line_item.details),
103
+ created_at: Time.now.utc
104
+ }
57
105
  end
58
106
 
59
- def stored_tag_value(value)
60
- if value.is_a?(Hash)
61
- return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
107
+ def insert_call_tags(events, call_ids)
108
+ rows = events.flat_map do |event|
109
+ (event.tags || {}).map do |key, value|
110
+ {
111
+ llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
112
+ key: key.to_s,
113
+ value: tag_row_value(value)
114
+ }
115
+ end
62
116
  end
117
+ return if rows.empty?
118
+
119
+ LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
120
+ end
121
+
122
+ def tag_row_value(value)
123
+ Tags::Encoding.encode(value)
124
+ end
63
125
 
64
- value.to_s
126
+ def stored_details(details)
127
+ (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
128
+ end
129
+
130
+ def increment_rollups_safely(events)
131
+ Ledger::Rollups.increment_many!(events)
132
+ rescue StandardError => e
133
+ raise if LlmCostTracker::Call.connection.open_transactions.positive?
134
+
135
+ LlmCostTracker::Logging.warn(
136
+ "Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
137
+ )
138
+ end
139
+
140
+ def insertable_events(events)
141
+ existing_ids = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
142
+ seen_ids = Set.new
143
+
144
+ events.select do |event|
145
+ event_id = event.event_id
146
+ !existing_ids.include?(event_id) && seen_ids.add?(event_id)
147
+ end
65
148
  end
66
149
  end
67
150
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Tags
8
+ module Encoding
9
+ module_function
10
+
11
+ def encode(value)
12
+ case value
13
+ when Hash then JSON.generate(normalize_hash(value))
14
+ when Array then JSON.generate(normalize_array(value))
15
+ else value.to_s
16
+ end
17
+ end
18
+
19
+ def normalize_hash(hash)
20
+ hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
21
+ end
22
+
23
+ def normalize_array(array)
24
+ array.map { |v| normalize_value(v) }
25
+ end
26
+
27
+ def normalize_value(value)
28
+ case value
29
+ when Hash then normalize_hash(value)
30
+ when Array then normalize_array(value)
31
+ else value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  require_relative "../schema/adapter"
4
+ require_relative "encoding"
6
5
 
7
6
  module LlmCostTracker
8
7
  module Ledger
@@ -10,16 +9,12 @@ module LlmCostTracker
10
9
  module Query
11
10
  class << self
12
11
  def apply(tags)
13
- normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
14
- return Ledger::Call.all if normalized_tags.empty?
15
-
16
- connection = Ledger::Call.connection
17
- json = normalized_tags.to_json
12
+ normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
13
+ return LlmCostTracker::Call.all if normalized_tags.empty?
18
14
 
19
- if Schema::Adapter.postgresql?(connection)
20
- Ledger::Call.where("tags @> ?::jsonb", json)
21
- else
22
- Ledger::Call.where("JSON_CONTAINS(tags, ?)", json)
15
+ normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
16
+ relation.where(id: LlmCostTracker::CallTag.where(key: key,
17
+ value: value).select(:llm_cost_tracker_call_id))
23
18
  end
24
19
  end
25
20
  end
@@ -1,31 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../schema/adapter"
4
3
  require_relative "../../tags/key"
5
4
 
6
5
  module LlmCostTracker
7
6
  module Ledger
8
7
  module Tags
9
8
  module Sql
9
+ UNTAGGED_LABEL = "(untagged)"
10
+
10
11
  class << self
11
- def value_expression(key, table_name:)
12
- key = LlmCostTracker::Tags::Key.validate!(key)
13
- connection = Ledger::Call.connection
14
- column = "#{table_name}.#{connection.quote_column_name('tags')}"
15
-
16
- if Ledger::Schema::Adapter.postgresql?(connection)
17
- "#{column}->>#{connection.quote(key)}"
18
- elsif Ledger::Schema::Adapter.mysql?(connection)
19
- "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
20
- else
21
- Ledger::Schema::Adapter.ensure_supported!(connection)
22
- end
12
+ def join_relation(scope, key)
13
+ validated_key = LlmCostTracker::Tags::Key.validate!(key)
14
+ connection = scope.connection
15
+ join = "LEFT OUTER JOIN #{call_tag_table} ON " \
16
+ "#{call_tag_table}.llm_cost_tracker_call_id = #{scope.quoted_table_name}.id AND " \
17
+ "#{call_tag_table}.#{connection.quote_column_name('key')} = #{connection.quote(validated_key)}"
18
+ scope.joins(join)
19
+ end
20
+
21
+ def value_arel
22
+ Arel.sql("#{call_tag_table}.#{quote_column('value')}")
23
+ end
24
+
25
+ def label_sql(connection)
26
+ "COALESCE(NULLIF(#{raw_value_sql(connection)}, ''), #{connection.quote(UNTAGGED_LABEL)})"
27
+ end
28
+
29
+ def raw_value_sql(connection)
30
+ "#{call_tag_table}.#{connection.quote_column_name('value')}"
23
31
  end
24
32
 
25
33
  private
26
34
 
27
- def json_path(key)
28
- "$.\"#{key}\""
35
+ def call_tag_table
36
+ LlmCostTracker::CallTag.quoted_table_name
37
+ end
38
+
39
+ def quote_column(name)
40
+ LlmCostTracker::CallTag.connection.quote_column_name(name)
29
41
  end
30
42
  end
31
43
  end
@@ -2,11 +2,14 @@
2
2
 
3
3
  require_relative "ledger/schema/adapter"
4
4
  require_relative "ledger/schema/calls"
5
- require_relative "ledger/schema/period_totals"
5
+ require_relative "ledger/schema/call_rollups"
6
+ require_relative "ledger/schema/call_line_items"
7
+ require_relative "ledger/schema/call_tags"
8
+ require_relative "ledger/schema/ingestion_inbox_entries"
9
+ require_relative "ledger/schema/ingestion_leases"
6
10
  require_relative "ledger/tags/query"
7
11
  require_relative "ledger/tags/sql"
8
12
  require_relative "ledger/period"
9
- require_relative "ledger/rollups/batch"
10
13
  require_relative "ledger/rollups/upsert_sql"
11
14
  require_relative "ledger/rollups"
12
15
  require_relative "ledger/store"
@@ -20,12 +20,9 @@ module LlmCostTracker
20
20
  def log(level, message)
21
21
  message = prefixed(message)
22
22
  logger = Rails.logger
23
+ return Kernel.warn(message) unless logger
23
24
 
24
- if logger
25
- logger.try(level, message)
26
- else
27
- Kernel.warn(message)
28
- end
25
+ logger.public_send(level, message)
29
26
  end
30
27
 
31
28
  private