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
@@ -1,33 +0,0 @@
1
- class AddIngestionToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
- def change
3
- add_column :llm_api_calls, :event_id, :string unless column_exists?(:llm_api_calls, :event_id)
4
- add_index :llm_api_calls, :event_id, unique: true if column_exists?(:llm_api_calls, :event_id) &&
5
- !index_exists?(:llm_api_calls, :event_id)
6
-
7
- create_table :llm_cost_tracker_inbox_events do |t|
8
- t.string :event_id, null: false
9
- t.decimal :total_cost, precision: 20, scale: 8
10
- t.datetime :tracked_at, null: false
11
- t.text :payload, null: false
12
- t.datetime :locked_at
13
- t.string :locked_by
14
- t.integer :attempts, null: false, default: 0
15
- t.text :last_error
16
-
17
- t.timestamps
18
- end unless table_exists?(:llm_cost_tracker_inbox_events)
19
-
20
- create_table :llm_cost_tracker_ingestor_leases do |t|
21
- t.string :name, null: false
22
- t.string :locked_by
23
- t.datetime :locked_until
24
-
25
- t.timestamps
26
- end unless table_exists?(:llm_cost_tracker_ingestor_leases)
27
-
28
- add_index :llm_cost_tracker_inbox_events, :event_id, unique: true unless index_exists?(:llm_cost_tracker_inbox_events, :event_id)
29
- add_index :llm_cost_tracker_inbox_events, :tracked_at unless index_exists?(:llm_cost_tracker_inbox_events, :tracked_at)
30
- add_index :llm_cost_tracker_inbox_events, [:locked_at, :id] unless index_exists?(:llm_cost_tracker_inbox_events, [:locked_at, :id])
31
- add_index :llm_cost_tracker_ingestor_leases, :name, unique: true unless index_exists?(:llm_cost_tracker_ingestor_leases, :name)
32
- end
33
- end
@@ -1,9 +0,0 @@
1
- class AddLatencyMsToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- add_column :llm_api_calls, :latency_ms, :integer unless column_exists?(:llm_api_calls, :latency_ms)
4
- end
5
-
6
- def down
7
- remove_column :llm_api_calls, :latency_ms if column_exists?(:llm_api_calls, :latency_ms)
8
- end
9
- end
@@ -1,104 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
4
- def up
5
- create_table :llm_cost_tracker_period_totals do |t|
6
- t.string :period, null: false
7
- t.date :period_start, null: false
8
- t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
9
-
10
- t.timestamps
11
- end unless table_exists?(:llm_cost_tracker_period_totals)
12
-
13
- backfill_period_totals
14
-
15
- add_index :llm_cost_tracker_period_totals, [:period, :period_start],
16
- unique: true unless index_exists?(:llm_cost_tracker_period_totals, [:period, :period_start])
17
- end
18
-
19
- def down
20
- remove_index :llm_cost_tracker_period_totals, [:period, :period_start] if index_exists?(:llm_cost_tracker_period_totals, [:period, :period_start])
21
- drop_table :llm_cost_tracker_period_totals if table_exists?(:llm_cost_tracker_period_totals)
22
- end
23
-
24
- private
25
-
26
- def backfill_period_totals
27
- backfill_legacy_monthly_totals if table_exists?(:llm_cost_tracker_monthly_totals)
28
- return unless table_exists?(:llm_api_calls)
29
-
30
- backfill_period_total("day", day_bucket_sql)
31
- backfill_period_total("month", month_bucket_sql)
32
- end
33
-
34
- def backfill_legacy_monthly_totals
35
- execute <<~SQL
36
- INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
37
- SELECT #{connection.quote("month")} AS period,
38
- month AS period_start,
39
- total_cost,
40
- CURRENT_TIMESTAMP,
41
- CURRENT_TIMESTAMP
42
- FROM llm_cost_tracker_monthly_totals legacy
43
- WHERE NOT EXISTS (
44
- SELECT 1
45
- FROM llm_cost_tracker_period_totals existing
46
- WHERE existing.period = #{connection.quote("month")}
47
- AND existing.period_start = legacy.month
48
- )
49
- SQL
50
- end
51
-
52
- def backfill_period_total(period, bucket_sql)
53
- execute <<~SQL
54
- INSERT INTO llm_cost_tracker_period_totals (period, period_start, total_cost, created_at, updated_at)
55
- SELECT aggregated.period,
56
- aggregated.period_start,
57
- aggregated.total_cost,
58
- CURRENT_TIMESTAMP,
59
- CURRENT_TIMESTAMP
60
- FROM (
61
- SELECT #{connection.quote(period)} AS period,
62
- #{bucket_sql} AS period_start,
63
- SUM(total_cost) AS total_cost
64
- FROM llm_api_calls
65
- WHERE total_cost IS NOT NULL
66
- GROUP BY #{bucket_sql}
67
- ) aggregated
68
- WHERE NOT EXISTS (
69
- SELECT 1
70
- FROM llm_cost_tracker_period_totals existing
71
- WHERE existing.period = aggregated.period
72
- AND existing.period_start = aggregated.period_start
73
- )
74
- SQL
75
- end
76
-
77
- def day_bucket_sql
78
- if postgresql?
79
- "DATE_TRUNC('day', tracked_at)::date"
80
- elsif mysql?
81
- "DATE(tracked_at)"
82
- else
83
- raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
84
- end
85
- end
86
-
87
- def month_bucket_sql
88
- if postgresql?
89
- "DATE_TRUNC('month', tracked_at)::date"
90
- elsif mysql?
91
- "DATE_FORMAT(tracked_at, '%Y-%m-01')"
92
- else
93
- raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
94
- end
95
- end
96
-
97
- def postgresql?
98
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
99
- end
100
-
101
- def mysql?
102
- LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
103
- end
104
- end
@@ -1,15 +0,0 @@
1
- class AddProviderResponseIdToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- return if column_exists?(:llm_api_calls, :provider_response_id)
4
-
5
- add_column :llm_api_calls, :provider_response_id, :string
6
- add_index :llm_api_calls, :provider_response_id
7
- end
8
-
9
- def down
10
- return unless column_exists?(:llm_api_calls, :provider_response_id)
11
-
12
- remove_index :llm_api_calls, :provider_response_id if index_exists?(:llm_api_calls, :provider_response_id)
13
- remove_column :llm_api_calls, :provider_response_id
14
- end
15
- end
@@ -1,21 +0,0 @@
1
- class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- unless column_exists?(:llm_api_calls, :stream)
4
- add_column :llm_api_calls, :stream, :boolean, null: false, default: false
5
- end
6
-
7
- unless column_exists?(:llm_api_calls, :usage_source)
8
- add_column :llm_api_calls, :usage_source, :string
9
- end
10
- end
11
-
12
- def down
13
- if column_exists?(:llm_api_calls, :usage_source)
14
- remove_column :llm_api_calls, :usage_source
15
- end
16
-
17
- if column_exists?(:llm_api_calls, :stream)
18
- remove_column :llm_api_calls, :stream
19
- end
20
- end
21
- end
@@ -1,22 +0,0 @@
1
- class AddTokenUsageToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- <% LlmCostTracker::Generators::AddTokenUsageGenerator::TOKEN_COLUMNS.each do |column| -%>
4
- unless column_exists?(:llm_api_calls, :<%= column %>)
5
- add_column :llm_api_calls, :<%= column %>, :integer, null: false, default: 0
6
- end
7
- <% end -%>
8
- <% LlmCostTracker::Generators::AddTokenUsageGenerator::COST_COLUMNS.each do |column| -%>
9
- unless column_exists?(:llm_api_calls, :<%= column %>)
10
- add_column :llm_api_calls, :<%= column %>, :decimal, precision: 20, scale: 8
11
- end
12
- <% end -%>
13
- add_column :llm_api_calls, :pricing_mode, :string unless column_exists?(:llm_api_calls, :pricing_mode)
14
- end
15
-
16
- def down
17
- remove_column :llm_api_calls, :pricing_mode if column_exists?(:llm_api_calls, :pricing_mode)
18
- <% (LlmCostTracker::Generators::AddTokenUsageGenerator::COST_COLUMNS + LlmCostTracker::Generators::AddTokenUsageGenerator::TOKEN_COLUMNS).reverse.each do |column| -%>
19
- remove_column :llm_api_calls, :<%= column %> if column_exists?(:llm_api_calls, :<%= column %>)
20
- <% end -%>
21
- end
22
- end
@@ -1,83 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
4
- def change
5
- create_table :llm_api_calls do |t|
6
- t.string :event_id, null: false
7
- t.string :provider, null: false
8
- t.string :model, null: false
9
- <% LlmCostTracker::TokenUsage::STORED_KEYS.each do |column| -%>
10
- t.integer :<%= column %>, null: false, default: 0
11
- <% end -%>
12
- <% LlmCostTracker::Pricing::COST_KEYS.each do |column| -%>
13
- t.decimal :<%= column %>, precision: 20, scale: 8
14
- <% end -%>
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 :pricing_mode
20
- if postgresql?
21
- t.jsonb :tags, null: false, default: {}
22
- elsif mysql?
23
- t.json :tags, null: false
24
- else
25
- raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
26
- end
27
- t.datetime :tracked_at, null: false
28
-
29
- t.timestamps
30
- end
31
-
32
- create_table :llm_cost_tracker_period_totals do |t|
33
- t.string :period, null: false
34
- t.date :period_start, null: false
35
- t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
36
-
37
- t.timestamps
38
- end
39
-
40
- create_table :llm_cost_tracker_inbox_events do |t|
41
- t.string :event_id, null: false
42
- t.decimal :total_cost, precision: 20, scale: 8
43
- t.datetime :tracked_at, null: false
44
- t.text :payload, null: false
45
- t.datetime :locked_at
46
- t.string :locked_by
47
- t.integer :attempts, null: false, default: 0
48
- t.text :last_error
49
-
50
- t.timestamps
51
- end
52
-
53
- create_table :llm_cost_tracker_ingestor_leases do |t|
54
- t.string :name, null: false
55
- t.string :locked_by
56
- t.datetime :locked_until
57
-
58
- t.timestamps
59
- end
60
-
61
- add_index :llm_api_calls, :event_id, unique: true
62
- add_index :llm_api_calls, :tracked_at
63
- add_index :llm_api_calls, [:provider, :tracked_at]
64
- add_index :llm_api_calls, [:model, :tracked_at]
65
- add_index :llm_api_calls, :provider_response_id
66
- add_index :llm_api_calls, :tags, using: :gin if postgresql?
67
- add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
68
- add_index :llm_cost_tracker_inbox_events, :event_id, unique: true
69
- add_index :llm_cost_tracker_inbox_events, :tracked_at
70
- add_index :llm_cost_tracker_inbox_events, [:locked_at, :id]
71
- add_index :llm_cost_tracker_ingestor_leases, :name, unique: true
72
- end
73
-
74
- private
75
-
76
- def postgresql?
77
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
78
- end
79
-
80
- def mysql?
81
- LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
82
- end
83
- end
@@ -1,26 +0,0 @@
1
- class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
2
- COST_COLUMNS = %i[
3
- input_cost
4
- cache_read_input_cost
5
- cache_write_input_cost
6
- cache_write_1h_input_cost
7
- output_cost
8
- total_cost
9
- ].freeze
10
-
11
- def up
12
- COST_COLUMNS.each do |column|
13
- next unless column_exists?(:llm_api_calls, column)
14
-
15
- change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
16
- end
17
- end
18
-
19
- def down
20
- COST_COLUMNS.each do |column|
21
- next unless column_exists?(:llm_api_calls, column)
22
-
23
- change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
24
- end
25
- end
26
- end
@@ -1,44 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
4
- def up
5
- unless postgresql?
6
- say "Skipping llm_api_calls.tags JSONB upgrade: database adapter is #{connection.adapter_name}."
7
- return
8
- end
9
-
10
- return if tags_jsonb?
11
-
12
- remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
13
- say "Upgrading llm_api_calls.tags to jsonb rewrites the table on PostgreSQL. Run this migration during a maintenance window on large datasets."
14
-
15
- change_column(
16
- :llm_api_calls,
17
- :tags,
18
- :jsonb,
19
- using: "CASE WHEN tags IS NULL OR tags = '' THEN '{}'::jsonb ELSE tags::jsonb END",
20
- default: {},
21
- null: false
22
- )
23
-
24
- add_index :llm_api_calls, :tags, using: :gin unless index_exists?(:llm_api_calls, :tags)
25
- end
26
-
27
- def down
28
- return unless postgresql?
29
-
30
- remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
31
- change_column :llm_api_calls, :tags, :text, using: "tags::text"
32
- end
33
-
34
- private
35
-
36
- def postgresql?
37
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
38
- end
39
-
40
- def tags_jsonb?
41
- column = connection.columns(:llm_api_calls).find { |candidate| candidate.name == "tags" }
42
- column&.sql_type.to_s.downcase == "jsonb"
43
- end
44
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module LlmCostTracker
7
- module Generators
8
- class UpgradeCostPrecisionGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Creates a migration to widen llm_api_calls cost decimal precision"
14
-
15
- def create_migration_file
16
- migration_template(
17
- "upgrade_llm_api_call_cost_precision.rb.erb",
18
- "db/migrate/upgrade_llm_api_call_cost_precision.rb"
19
- )
20
- end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
- end
28
- end
29
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module LlmCostTracker
7
- module Generators
8
- class UpgradeTagsToJsonbGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Creates a migration to upgrade llm_api_calls.tags to PostgreSQL JSONB"
14
-
15
- def create_migration_file
16
- migration_template(
17
- "upgrade_llm_api_call_tags_to_jsonb.rb.erb",
18
- "db/migrate/upgrade_llm_api_call_tags_to_jsonb.rb"
19
- )
20
- end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
- end
28
- end
29
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "../period"
6
-
7
- module LlmCostTracker
8
- module Ledger
9
- class Rollups
10
- class Batch
11
- def self.rows(events)
12
- new(events).rows
13
- end
14
-
15
- def initialize(events)
16
- @events = events
17
- end
18
-
19
- def rows
20
- totals.map do |(period, period_start), total_cost|
21
- {
22
- period: period,
23
- period_start: period_start,
24
- total_cost: total_cost
25
- }
26
- end
27
- end
28
-
29
- private
30
-
31
- attr_reader :events
32
-
33
- def totals
34
- events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
35
- Period::PERIODS.each do |period, name|
36
- rows[[name, Period.bucket(period, event.tracked_at)]] += BigDecimal(event.total_cost.to_s)
37
- end
38
- end
39
- end
40
- end
41
- end
42
- end
43
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Ledger
5
- module Schema
6
- module PeriodTotals
7
- REQUIRED_COLUMNS = %w[period period_start total_cost].freeze
8
- UNIQUE_COLUMNS = %i[period period_start].freeze
9
-
10
- class << self
11
- def current_schema_errors
12
- connection = Ledger::Call.connection
13
- table_name = Ledger::Period::Total.table_name
14
- return ["llm_cost_tracker_period_totals table is missing"] unless connection.data_source_exists?(table_name)
15
-
16
- errors = []
17
- missing = REQUIRED_COLUMNS - Ledger::Period::Total.columns_hash.keys
18
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
19
- errors << "missing unique index: period, period_start" unless unique_period_index?(connection, table_name)
20
- errors
21
- end
22
-
23
- private
24
-
25
- def unique_period_index?(connection, table_name)
26
- connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
27
- end
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Pricing
5
- Component = Data.define(:price_key, :token_key, :cost_key)
6
-
7
- COMPONENTS = [
8
- Component.new(
9
- price_key: :input,
10
- token_key: :input_tokens,
11
- cost_key: :input_cost
12
- ),
13
- Component.new(
14
- price_key: :cache_read_input,
15
- token_key: :cache_read_input_tokens,
16
- cost_key: :cache_read_input_cost
17
- ),
18
- Component.new(
19
- price_key: :cache_write_input,
20
- token_key: :cache_write_input_tokens,
21
- cost_key: :cache_write_input_cost
22
- ),
23
- Component.new(
24
- price_key: :cache_write_1h_input,
25
- token_key: :cache_write_1h_input_tokens,
26
- cost_key: :cache_write_1h_input_cost
27
- ),
28
- Component.new(
29
- price_key: :output,
30
- token_key: :output_tokens,
31
- cost_key: :output_cost
32
- )
33
- ].freeze
34
-
35
- COST_KEYS = (COMPONENTS.map(&:cost_key) + %i[total_cost]).freeze
36
- end
37
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "yaml"
5
-
6
- require_relative "../registry"
7
-
8
- module LlmCostTracker
9
- module Pricing
10
- module Sync
11
- class RegistryLoader
12
- YAML_EXTENSIONS = %w[.yml .yaml].freeze
13
-
14
- def call(path:, seed_path:)
15
- source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
16
- normalize_registry(load_registry_file(source_path))
17
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
18
- raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
19
- end
20
-
21
- private
22
-
23
- def load_registry_file(path)
24
- if File.size(path) > Registry::MAX_FILE_BYTES
25
- raise ArgumentError, "pricing registry exceeds #{Registry::MAX_FILE_BYTES} bytes"
26
- end
27
-
28
- contents = File.read(path)
29
- registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
30
- raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
31
-
32
- registry
33
- end
34
-
35
- def normalize_registry(registry)
36
- {
37
- "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
38
- "models" => normalize_models(registry.fetch("models", {}))
39
- }
40
- end
41
-
42
- def normalize_models(models)
43
- normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
44
- normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
45
- end
46
- end
47
-
48
- def normalize_hash(hash, label:)
49
- return {} if hash.nil?
50
- raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
51
-
52
- hash.each_with_object({}) do |(key, value), normalized|
53
- normalized[key.to_s] = value
54
- end
55
- end
56
-
57
- def yaml_file?(path)
58
- YAML_EXTENSIONS.include?(File.extname(path).downcase)
59
- end
60
- end
61
- end
62
- end
63
- end