llm_cost_tracker 0.7.2 → 0.8.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -6,16 +6,12 @@ module LlmCostTracker
6
6
  class InvalidFilterError < Error; end
7
7
 
8
8
  class BudgetExceededError < Error
9
- attr_reader :monthly_total, :daily_total, :call_cost, :total, :budget, :budget_type, :last_event
10
-
11
- def initialize(budget:, last_event: nil, budget_type: nil, total: nil, monthly_total: nil, daily_total: nil,
12
- call_cost: nil)
13
- @monthly_total = monthly_total
14
- @daily_total = daily_total
15
- @call_cost = call_cost
16
- @total = total || monthly_total || daily_total || call_cost
9
+ attr_reader :total, :budget, :budget_type, :last_event
10
+
11
+ def initialize(budget:, budget_type:, total:, last_event: nil)
12
+ @total = total
17
13
  @budget = budget
18
- @budget_type = budget_type || inferred_budget_type
14
+ @budget_type = budget_type
19
15
  @last_event = last_event
20
16
 
21
17
  super(
@@ -23,16 +19,6 @@ module LlmCostTracker
23
19
  "$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
24
20
  )
25
21
  end
26
-
27
- private
28
-
29
- def inferred_budget_type
30
- return :monthly if monthly_total
31
- return :daily if daily_total
32
- return :per_call if call_cost
33
-
34
- :unknown
35
- end
36
22
  end
37
23
 
38
24
  class UnknownPricingError < Error
@@ -13,7 +13,14 @@ module LlmCostTracker
13
13
  :stream,
14
14
  :usage_source,
15
15
  :provider_response_id,
16
- :tracked_at
16
+ :provider_project_id,
17
+ :provider_api_key_id,
18
+ :provider_workspace_id,
19
+ :batch,
20
+ :tracked_at,
21
+ :cost_status,
22
+ :pricing_snapshot,
23
+ :line_items
17
24
  ) do
18
25
  def total_cost
19
26
  cost&.fetch(:total_cost, nil)
@@ -23,7 +30,8 @@ module LlmCostTracker
23
30
  super.merge(
24
31
  token_usage: token_usage.to_h,
25
32
  cost: cost&.to_h,
26
- tags: tags ? tags.to_h : {}
33
+ tags: tags ? tags.to_h : {},
34
+ line_items: (line_items || []).map(&:to_h)
27
35
  )
28
36
  end
29
37
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require "llm_cost_tracker/billing/components"
6
+ require "llm_cost_tracker/billing/cost_status"
5
7
  require "llm_cost_tracker/pricing"
6
8
  require "llm_cost_tracker/token_usage"
7
9
 
@@ -18,8 +20,8 @@ module LlmCostTracker
18
20
 
19
21
  def create_migration_file
20
22
  migration_template(
21
- "create_llm_api_calls.rb.erb",
22
- "db/migrate/create_llm_api_calls.rb"
23
+ "create_llm_cost_tracker_calls.rb.erb",
24
+ "db/migrate/create_llm_cost_tracker_calls.rb"
23
25
  )
24
26
  end
25
27
 
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
+ require "yaml"
4
5
 
5
6
  require_relative "../../pricing/registry"
6
- require_relative "../../pricing/sync/registry_loader"
7
7
  require_relative "../../pricing/sync/registry_writer"
8
8
 
9
9
  module LlmCostTracker
@@ -12,13 +12,9 @@ module LlmCostTracker
12
12
  desc "Creates a local LLM Cost Tracker price snapshot"
13
13
 
14
14
  def create_prices_file
15
- registry = LlmCostTracker::Pricing::Sync::RegistryLoader.new.call(
16
- path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH,
17
- seed_path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH
18
- )
19
15
  LlmCostTracker::Pricing::Sync::RegistryWriter.new.call(
20
16
  path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
21
- registry: registry
17
+ registry: YAML.safe_load_file(LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
22
18
  )
23
19
  end
24
20
  end
@@ -0,0 +1,157 @@
1
+ require "llm_cost_tracker/billing/components"
2
+ require "llm_cost_tracker/billing/cost_status"
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
6
+ def change
7
+ create_table :llm_cost_tracker_calls do |t|
8
+ t.string :event_id, null: false
9
+ t.string :provider, null: false
10
+ t.string :model, null: false
11
+ <% LlmCostTracker::TokenUsage.members.each do |column| -%>
12
+ t.integer :<%= column %>, null: false, default: 0
13
+ <% end -%>
14
+ t.decimal :total_cost, precision: 20, scale: 8
15
+ t.integer :latency_ms
16
+ t.boolean :stream, null: false, default: false
17
+ t.string :usage_source
18
+ t.string :provider_response_id
19
+ t.string :provider_project_id
20
+ t.string :provider_api_key_id
21
+ t.string :provider_workspace_id
22
+ t.boolean :batch, null: false, default: false
23
+ t.string :pricing_mode
24
+ t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
25
+ if postgresql?
26
+ t.jsonb :pricing_snapshot
27
+ elsif mysql?
28
+ t.json :pricing_snapshot
29
+ else
30
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
31
+ end
32
+ t.datetime :tracked_at, null: false
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ create_table :llm_cost_tracker_call_rollups do |t|
38
+ t.string :period, null: false
39
+ t.date :period_start, null: false
40
+ t.string :currency, null: false, default: "USD"
41
+ t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
42
+
43
+ t.timestamps
44
+ end
45
+
46
+ create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
47
+ t.string :event_id, null: false
48
+ t.decimal :total_cost, precision: 20, scale: 8
49
+ t.datetime :tracked_at, null: false
50
+ t.text :payload, null: false
51
+ t.datetime :locked_at
52
+ t.string :locked_by
53
+ t.integer :attempts, null: false, default: 0
54
+ t.text :last_error
55
+
56
+ t.timestamps
57
+ end
58
+
59
+ create_table :llm_cost_tracker_ingestion_leases do |t|
60
+ t.string :name, null: false
61
+ t.string :locked_by
62
+ t.datetime :locked_until
63
+
64
+ t.timestamps
65
+ end
66
+
67
+ create_table :llm_cost_tracker_call_line_items do |t|
68
+ t.references :llm_cost_tracker_call,
69
+ null: false,
70
+ index: false,
71
+ foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
72
+ t.integer :position, null: false, default: 0, limit: 2
73
+ t.string :kind, null: false
74
+ t.string :direction, null: false
75
+ t.string :modality, null: false
76
+ t.string :cache_state, null: false, default: "none"
77
+ t.decimal :quantity, precision: 30, scale: 10, null: false
78
+ t.string :unit, null: false
79
+ t.decimal :rate_amount, precision: 20, scale: 8
80
+ t.decimal :rate_quantity, precision: 30, scale: 10, null: false, default: 1
81
+ t.decimal :cost, precision: 20, scale: 8
82
+ t.string :currency, null: false, default: "USD"
83
+ t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
84
+ t.string :pricing_basis
85
+ t.string :price_key
86
+ t.string :price_source
87
+ t.string :price_source_version
88
+ t.string :provider_field
89
+ t.string :provider_item_id
90
+ if postgresql?
91
+ t.jsonb :details, null: false, default: {}
92
+ elsif mysql?
93
+ t.json :details, null: false
94
+ else
95
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
96
+ end
97
+
98
+ t.datetime :created_at, null: false
99
+ end
100
+
101
+ create_table :llm_cost_tracker_call_tags do |t|
102
+ t.references :llm_cost_tracker_call,
103
+ null: false,
104
+ index: false,
105
+ foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
106
+ t.string :key, null: false
107
+ t.text :value, null: false
108
+ end
109
+
110
+ create_table :llm_cost_tracker_provider_invoices do |t|
111
+ t.string :source, null: false
112
+ t.date :period_start, null: false
113
+ t.date :period_end, null: false
114
+ t.string :external_id, null: false
115
+ t.decimal :billed_amount, precision: 20, scale: 8
116
+ t.string :currency, null: false, default: "USD"
117
+ if postgresql?
118
+ t.jsonb :metadata, null: false, default: {}
119
+ elsif mysql?
120
+ t.json :metadata, null: false
121
+ else
122
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
123
+ end
124
+ t.datetime :imported_at, null: false
125
+
126
+ t.timestamps
127
+ end
128
+
129
+ add_index :llm_cost_tracker_calls, :event_id, unique: true
130
+ add_index :llm_cost_tracker_calls, :tracked_at
131
+ add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
132
+ add_index :llm_cost_tracker_calls, [:model, :tracked_at]
133
+ add_index :llm_cost_tracker_calls, :cost_status
134
+ add_index :llm_cost_tracker_calls, :provider_response_id
135
+ add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
136
+ add_index :llm_cost_tracker_call_line_items, :kind
137
+ add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
138
+ add_index :llm_cost_tracker_call_tags, :key
139
+ add_index :llm_cost_tracker_call_rollups, [:period, :period_start, :currency], unique: true
140
+ add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
141
+ add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
142
+ add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
143
+ add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
144
+ add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true
145
+ add_index :llm_cost_tracker_provider_invoices, [:source, :period_start]
146
+ end
147
+
148
+ private
149
+
150
+ def postgresql?
151
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
152
+ end
153
+
154
+ def mysql?
155
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
156
+ end
157
+ end
@@ -27,7 +27,7 @@ module LlmCostTracker
27
27
  end
28
28
 
29
29
  def pending?
30
- Ingestion::Event.where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS).exists?
30
+ Ingestion::InboxEntry.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE).exists?
31
31
  end
32
32
 
33
33
  def claimable?
@@ -37,7 +37,7 @@ module LlmCostTracker
37
37
  def mark_failed(rows, error)
38
38
  message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
39
39
  now = Time.now.utc
40
- Ingestion::Event
40
+ Ingestion::InboxEntry
41
41
  .where(id: rows.map(&:id), locked_by: identity)
42
42
  .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
43
43
  rescue StandardError
@@ -51,16 +51,15 @@ module LlmCostTracker
51
51
  def claim
52
52
  now = Time.now.utc
53
53
  cutoff = now - LOCK_TIMEOUT_SECONDS
54
- Ingestion::Event.transaction do
54
+ Ingestion::InboxEntry.transaction do
55
55
  rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
56
- ids = rows.map(&:id)
57
- next [] if ids.empty?
56
+ next [] if rows.empty?
58
57
 
59
- updates = Ingestion::Event.sanitize_sql_array(
58
+ updates = Ingestion::InboxEntry.sanitize_sql_array(
60
59
  ["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
61
60
  )
62
- Ingestion::Event.where(id: ids).update_all(updates)
63
- Ingestion::Event.where(id: ids, locked_by: identity).order(:id).to_a
61
+ Ingestion::InboxEntry.where(id: rows.map(&:id)).update_all(updates)
62
+ rows
64
63
  end
65
64
  end
66
65
 
@@ -77,15 +76,15 @@ module LlmCostTracker
77
76
  end
78
77
 
79
78
  def persist(rows, events)
80
- LlmCostTracker::Ledger::Call.transaction do
79
+ LlmCostTracker::Call.transaction do
81
80
  Ledger::Store.insert_many(events)
82
- Ingestion::Event.where(id: rows.map(&:id), locked_by: identity).delete_all
81
+ Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
83
82
  end
84
83
  end
85
84
 
86
85
  def claimable_scope(cutoff)
87
- Ingestion::Event
88
- .where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS)
86
+ Ingestion::InboxEntry
87
+ .where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE)
89
88
  .where("locked_at IS NULL OR locked_at < ?", cutoff)
90
89
  end
91
90
  end
@@ -5,11 +5,12 @@ require "time"
5
5
 
6
6
  require_relative "../event"
7
7
  require_relative "../pricing"
8
+ require_relative "../billing/line_item"
8
9
 
9
10
  module LlmCostTracker
10
11
  module Ingestion
11
12
  class Inbox
12
- PAYLOAD_SCHEMA_VERSION = 1
13
+ PAYLOAD_SCHEMA_VERSION = 2
13
14
 
14
15
  class << self
15
16
  def save(event)
@@ -19,32 +20,47 @@ module LlmCostTracker
19
20
  end
20
21
 
21
22
  def event_from_row(row)
22
- payload = JSON.parse(row.payload)
23
- schema_version = payload.fetch("schema_version", 0)
24
- unless [0, PAYLOAD_SCHEMA_VERSION].include?(schema_version)
23
+ payload = JSON.parse(row.payload, symbolize_names: true)
24
+ schema_version = payload[:schema_version]
25
+ unless schema_version == PAYLOAD_SCHEMA_VERSION
25
26
  raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
26
27
  end
27
28
 
28
- cost = payload["cost"] && Pricing.stored_cost_attributes(payload["cost"])
29
- token_usage = payload["token_usage"] || payload
29
+ LlmCostTracker::Event.new(**event_attributes_from(payload))
30
+ end
31
+
32
+ private
30
33
 
31
- LlmCostTracker::Event.new(
32
- event_id: payload.fetch("event_id"),
33
- provider: payload.fetch("provider"),
34
- model: payload.fetch("model"),
35
- token_usage: TokenUsage.from_hash(token_usage),
36
- pricing_mode: payload["pricing_mode"],
34
+ def event_attributes_from(payload)
35
+ cost = payload[:cost] && Pricing.stored_cost_attributes(payload[:cost])
36
+ token_usage = TokenUsage.build(**payload.fetch(:token_usage).slice(*TokenUsage.members))
37
+
38
+ {
39
+ event_id: payload.fetch(:event_id),
40
+ provider: payload.fetch(:provider),
41
+ model: payload.fetch(:model),
42
+ token_usage: token_usage,
43
+ pricing_mode: Pricing.normalize_mode(payload[:pricing_mode]),
37
44
  cost: cost,
38
- tags: payload.fetch("tags"),
39
- latency_ms: payload["latency_ms"],
40
- stream: payload.fetch("stream"),
41
- usage_source: payload["usage_source"],
42
- provider_response_id: payload["provider_response_id"],
43
- tracked_at: Time.iso8601(payload.fetch("tracked_at"))
44
- )
45
+ tags: payload.fetch(:tags),
46
+ latency_ms: payload[:latency_ms],
47
+ stream: payload.fetch(:stream),
48
+ usage_source: payload[:usage_source]&.to_sym,
49
+ provider_response_id: payload[:provider_response_id],
50
+ provider_project_id: payload[:provider_project_id],
51
+ provider_api_key_id: payload[:provider_api_key_id],
52
+ provider_workspace_id: payload[:provider_workspace_id],
53
+ batch: payload.fetch(:batch),
54
+ tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
55
+ cost_status: payload.fetch(:cost_status),
56
+ pricing_snapshot: payload[:pricing_snapshot],
57
+ line_items: line_items_from(payload)
58
+ }
45
59
  end
46
60
 
47
- private
61
+ def line_items_from(payload)
62
+ (payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
63
+ end
48
64
 
49
65
  def row_for(event)
50
66
  now = Time.now.utc
@@ -70,7 +86,7 @@ module LlmCostTracker
70
86
  end
71
87
 
72
88
  def insert_row(row)
73
- connection = LlmCostTracker::Ledger::Call.connection
89
+ connection = LlmCostTracker::Call.connection
74
90
  if connection.transaction_open?
75
91
  insert_with_separate_connection(row)
76
92
  else
@@ -82,7 +98,7 @@ module LlmCostTracker
82
98
  end
83
99
 
84
100
  def insert_with_separate_connection(row)
85
- pool = LlmCostTracker::Ledger::Call.connection_pool
101
+ pool = LlmCostTracker::Call.connection_pool
86
102
  connection = pool.checkout
87
103
  begin
88
104
  connection.transaction(requires_new: true) { execute_insert(connection, row) }
@@ -95,7 +111,7 @@ module LlmCostTracker
95
111
  columns = row.keys
96
112
  quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
97
113
  quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
98
- table = connection.quote_table_name(Event.table_name)
114
+ table = connection.quote_table_name(InboxEntry.table_name)
99
115
 
100
116
  connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
101
117
  end
@@ -25,18 +25,18 @@ module LlmCostTracker
25
25
  @generation = @generation.to_i + 1
26
26
  generation = @generation
27
27
  @thread = Thread.new { run(generation) }
28
- @thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
29
- @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
28
+ @thread.name = "llm_cost_tracker_ingestor"
29
+ @thread.report_on_exception = false
30
30
  end
31
31
  @thread
32
32
  end
33
33
  wake_thread(thread)
34
34
  end
35
35
 
36
- def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
36
+ def flush!(timeout: nil, require_lease: false)
37
37
  Ingestion.ensure_current_schema!
38
38
 
39
- deadline = Time.now.utc + timeout
39
+ deadline = Time.now.utc + flush_timeout_seconds(timeout)
40
40
  loop do
41
41
  return true unless Ingestion::Batch.new(identity: identity).pending?
42
42
  return false if Time.now.utc >= deadline
@@ -51,7 +51,8 @@ module LlmCostTracker
51
51
  end
52
52
  end
53
53
 
54
- def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
54
+ def shutdown!(timeout: nil, drain: true)
55
+ timeout ||= FLUSH_TIMEOUT_SECONDS
55
56
  thread = mutex.synchronize do
56
57
  @stop_requested = true
57
58
  @generation = @generation.to_i + 1
@@ -80,7 +81,15 @@ module LlmCostTracker
80
81
  wake_thread(thread)
81
82
  end
82
83
 
84
+ def flush_timeout_seconds(timeout)
85
+ numeric = Float(timeout, exception: false)
86
+ return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
87
+
88
+ numeric
89
+ end
90
+
83
91
  def ingest_once(require_lease: true)
92
+ Ingestion.ensure_current_schema!
84
93
  batch = Ingestion::Batch.new(identity: identity)
85
94
  return 0 unless batch.claimable?
86
95
  return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
@@ -15,31 +15,38 @@ module LlmCostTracker
15
15
  VERIFY_TAG = "llm_cost_tracker_verify"
16
16
 
17
17
  class << self
18
+ def table_name_prefix
19
+ "llm_cost_tracker_ingestion_"
20
+ end
21
+
22
+ WRITE_SCHEMA_GUARDS = [
23
+ ["llm_cost_tracker_calls", Ledger::Schema::Calls],
24
+ ["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
25
+ ["llm_cost_tracker_call_tags", Ledger::Schema::CallTags],
26
+ ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups]
27
+ ].freeze
28
+
18
29
  def ensure_current_schema!
19
- unless Ledger::Call.table_exists?
20
- raise Error, "llm_api_calls table is missing; run install generator and migrate"
30
+ unless LlmCostTracker::Call.table_exists?
31
+ raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
21
32
  end
22
33
 
23
- schema_errors = Ledger::Schema::Calls.current_schema_errors
24
- message = "llm_api_calls table is not on the current schema: #{schema_errors.join('; ')}"
25
- raise Error, message if schema_errors.any?
34
+ WRITE_SCHEMA_GUARDS.each do |table_name, schema_module|
35
+ errors = schema_module.current_schema_errors
36
+ next if errors.empty?
26
37
 
27
- period_total_errors = Ledger::Schema::PeriodTotals.current_schema_errors
28
- return if period_total_errors.empty?
29
-
30
- message = "llm_cost_tracker_period_totals table is not on the current schema: " \
31
- "#{period_total_errors.join('; ')}; " \
32
- "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
33
- raise Error, message
38
+ raise Error,
39
+ "#{table_name} table is not on the current schema: #{errors.join('; ')}; see docs/upgrading.md"
40
+ end
34
41
  end
35
42
 
36
43
  def verify
37
- unless LlmCostTracker::Ledger::Call.table_exists?
44
+ unless LlmCostTracker::Call.table_exists?
38
45
  return [
39
46
  LlmCostTracker::Doctor::Check.new(
40
47
  :error,
41
48
  "active_record",
42
- "llm_api_calls table is missing; run install generator and migrate"
49
+ "llm_cost_tracker_calls table is missing; run install generator and migrate"
43
50
  )
44
51
  ]
45
52
  end
@@ -60,13 +67,12 @@ module LlmCostTracker
60
67
  event = LlmCostTracker.track(
61
68
  provider: provider,
62
69
  model: model,
63
- input_tokens: 1,
64
- output_tokens: 1,
70
+ tokens: { input: 1, output: 1 },
65
71
  provider_response_id: response_id,
66
- feature: VERIFY_TAG
72
+ tags: { feature: VERIFY_TAG }
67
73
  )
68
- LlmCostTracker.flush!
69
- persisted = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id).exists?
74
+ LlmCostTracker::Ingestion::Worker.flush!
75
+ persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
70
76
 
71
77
  return capture_success if persisted && notifications.any?
72
78
 
@@ -83,7 +89,7 @@ module LlmCostTracker
83
89
  LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
84
90
  ensure
85
91
  cleanup_verification_call(response_id) if response_id
86
- LlmCostTracker::Ingestion::Event.where(event_id: event.event_id).delete_all if event
92
+ LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all if event
87
93
  ActiveSupport::Notifications.unsubscribe(subscription) if subscription
88
94
  end
89
95
 
@@ -109,8 +115,8 @@ module LlmCostTracker
109
115
  end
110
116
 
111
117
  def cleanup_verification_call(response_id)
112
- relation = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id)
113
- rows = relation.pluck(:id, :tracked_at, :total_cost)
118
+ relation = LlmCostTracker::Call.where(provider_response_id: response_id)
119
+ rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
114
120
  return if rows.empty?
115
121
 
116
122
  relation.delete_all