llm_cost_tracker 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,27 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class UpgradeLlmCostTrackerProviderInvoicesMetadataIndex < ActiveRecord::Migration<%= migration_version %>
4
- disable_ddl_transaction!
5
-
6
- TABLE = :llm_cost_tracker_provider_invoices
7
-
8
- def up
9
- return unless postgresql?
10
- return unless table_exists?(TABLE)
11
-
12
- add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
13
- end
14
-
15
- def down
16
- return unless postgresql?
17
- return unless table_exists?(TABLE)
18
-
19
- remove_index TABLE, column: :metadata, if_exists: true
20
- end
21
-
22
- private
23
-
24
- def postgresql?
25
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
26
- end
27
- end
@@ -1,31 +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 UpgradeProviderInvoiceImportsProviderGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Adds a provider column to llm_cost_tracker_provider_invoice_imports and a " \
14
- "(source, provider, started_at) index so resume_cursor_for and " \
15
- "last_completed_window_for can isolate per-provider state on shared sources (e.g. csv)."
16
-
17
- def create_migration_file
18
- migration_template(
19
- "upgrade_provider_invoice_imports_provider.rb.erb",
20
- "db/migrate/upgrade_llm_cost_tracker_provider_invoice_imports_provider.rb"
21
- )
22
- end
23
-
24
- private
25
-
26
- def migration_version
27
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
28
- end
29
- end
30
- end
31
- end
@@ -1,31 +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 UpgradeProviderInvoicesMetadataIndexGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Adds a GIN index on llm_cost_tracker_provider_invoices.metadata for PostgreSQL " \
14
- "so Reconciliation::Diff queries that filter on metadata->>'provider' / 'row_type' / " \
15
- "'match_basis' hit an index instead of a sequential scan. No-op on MySQL."
16
-
17
- def create_migration_file
18
- migration_template(
19
- "upgrade_provider_invoices_metadata_index.rb.erb",
20
- "db/migrate/upgrade_llm_cost_tracker_provider_invoices_metadata_index.rb"
21
- )
22
- end
23
-
24
- private
25
-
26
- def migration_version
27
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
28
- end
29
- end
30
- end
31
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../schema/adapter"
4
-
5
- module LlmCostTracker
6
- module Ledger
7
- class Rollups
8
- class UpsertSql
9
- def self.call
10
- new.call
11
- end
12
-
13
- def call
14
- return Arel.sql(mysql_sql) if Ledger::Schema::Adapter.mysql?(connection)
15
- return Arel.sql(postgres_sql) if Ledger::Schema::Adapter.postgresql?(connection)
16
-
17
- Ledger::Schema::Adapter.ensure_supported!(connection)
18
- end
19
-
20
- private
21
-
22
- def postgres_sql
23
- total_cost = connection.quote_column_name("total_cost")
24
- updated_at = connection.quote_column_name("updated_at")
25
-
26
- "#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
27
- "#{updated_at} = excluded.#{updated_at}"
28
- end
29
-
30
- def mysql_sql
31
- "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
32
- end
33
-
34
- def connection
35
- LlmCostTracker::CallRollup.connection
36
- end
37
- end
38
- end
39
- end
40
- end
@@ -1,57 +0,0 @@
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
- columns = LlmCostTracker::Ingestion::InboxEntry.columns_hash
32
- cache = @schema_capabilities
33
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
34
-
35
- errors = compute_errors(connection, table_name, columns)
36
- @schema_capabilities = { columns: columns, errors: errors }
37
- errors
38
- end
39
-
40
- private
41
-
42
- def compute_errors(connection, table_name, columns)
43
- errors = []
44
- missing = REQUIRED_COLUMNS - columns.keys
45
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
46
- errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
47
- errors
48
- end
49
-
50
- def event_id_unique_index?(connection, table_name)
51
- connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
52
- end
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,52 +0,0 @@
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
- columns = LlmCostTracker::Ingestion::Lease.columns_hash
27
- cache = @schema_capabilities
28
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
29
-
30
- errors = compute_errors(connection, table_name, columns)
31
- @schema_capabilities = { columns: columns, errors: errors }
32
- errors
33
- end
34
-
35
- private
36
-
37
- def compute_errors(connection, table_name, columns)
38
- errors = []
39
- missing = REQUIRED_COLUMNS - columns.keys
40
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
41
- errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
42
- errors
43
- end
44
-
45
- def name_unique_index?(connection, table_name)
46
- connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
47
- end
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,56 +0,0 @@
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 provider cursor window_start window_end state last_error
11
- rows_imported started_at finished_at
12
- ].freeze
13
- SOURCE_PROVIDER_STARTED_AT_INDEX = %i[source provider 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
- columns = LlmCostTracker::ProviderInvoiceImport.columns_hash
23
- cache = @schema_capabilities
24
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
-
26
- errors = compute_errors(connection, table_name, columns)
27
- @schema_capabilities = { columns: columns, errors: errors }
28
- errors
29
- end
30
-
31
- private
32
-
33
- def compute_errors(connection, table_name, columns)
34
- errors = []
35
- errors.concat(column_errors(columns))
36
- errors.concat(index_errors(connection, table_name))
37
- errors
38
- end
39
-
40
- def column_errors(columns)
41
- missing = REQUIRED_COLUMNS - columns.keys
42
- return [] if missing.empty?
43
-
44
- ["missing columns: #{missing.join(', ')}"]
45
- end
46
-
47
- def index_errors(connection, table_name)
48
- return [] if connection.index_exists?(table_name, SOURCE_PROVIDER_STARTED_AT_INDEX)
49
-
50
- ["missing index: source, provider, started_at"]
51
- end
52
- end
53
- end
54
- end
55
- end
56
- end
@@ -1,72 +0,0 @@
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
- columns = LlmCostTracker::ProviderInvoice.columns_hash
23
- cache = @schema_capabilities
24
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
-
26
- errors = compute_errors(connection, table_name, columns)
27
- @schema_capabilities = { columns: columns, errors: errors }
28
- errors
29
- end
30
-
31
- private
32
-
33
- def compute_errors(connection, table_name, columns)
34
- errors = []
35
- errors.concat(column_errors(columns))
36
- errors.concat(Adapter.json_column_errors(columns["metadata"], connection, "metadata"))
37
- errors.concat(index_errors(connection, table_name))
38
- errors
39
- end
40
-
41
- def column_errors(columns)
42
- missing = REQUIRED_COLUMNS - columns.keys
43
- return [] if missing.empty?
44
-
45
- ["missing columns: #{missing.join(', ')}"]
46
- end
47
-
48
- def index_errors(connection, table_name)
49
- errors = []
50
- unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
51
- errors << "missing unique index: external_id"
52
- end
53
- unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
54
- errors << "missing index: source, currency, period_start"
55
- end
56
- if Adapter.postgresql?(connection) && !gin_metadata_index?(connection, table_name)
57
- errors << "missing GIN index on metadata " \
58
- "(run bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index)"
59
- end
60
- errors
61
- end
62
-
63
- def gin_metadata_index?(connection, table_name)
64
- connection.indexes(table_name).any? do |index|
65
- index.columns == ["metadata"] && index.using.to_s == "gin"
66
- end
67
- end
68
- end
69
- end
70
- end
71
- end
72
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Masking
5
- SENSITIVE_KEYS = %i[
6
- provider_api_key_id provider_workspace_id provider_organization_id provider_project_id
7
- ].to_set.freeze
8
- MASK_TAIL_LENGTH = 4
9
-
10
- module_function
11
-
12
- def mask_value(key, value)
13
- string = value.to_s
14
- return string unless SENSITIVE_KEYS.include?(key.to_sym)
15
- return string if string.length <= MASK_TAIL_LENGTH
16
-
17
- "***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
18
- end
19
-
20
- def format_attribution(attribution, separator: ", ")
21
- return "" if attribution.nil? || attribution.empty?
22
-
23
- attribution.map { |key, value| "#{key}=#{mask_value(key, value)}" }.join(separator)
24
- end
25
-
26
- def mask_hash(hash)
27
- return hash unless hash.is_a?(Hash)
28
-
29
- hash.each_with_object({}) do |(key, value), masked|
30
- masked[key] = case value
31
- when Hash then mask_hash(value)
32
- when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
33
- else
34
- mask_value(key, value)
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,176 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "../providers/anthropic/server_tools"
5
- require_relative "../providers/anthropic/tier_classification"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Anthropic < Base
10
- HOSTS = %w[api.anthropic.com].freeze
11
-
12
- class << self
13
- def match?(url)
14
- match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
15
- end
16
-
17
- def provider_names
18
- %w[anthropic]
19
- end
20
- end
21
-
22
- def parse(request_body:, response_status:, response_body:, **)
23
- return nil unless response_status == 200
24
-
25
- response = safe_json_parse(response_body)
26
- usage = response["usage"]
27
- return nil unless usage
28
-
29
- request = safe_json_parse(request_body)
30
- cache_read = usage["cache_read_input_tokens"].to_i
31
-
32
- Event.build(
33
- provider: "anthropic",
34
- provider_response_id: response["id"],
35
- pricing_mode: pricing_mode(request: request, usage: usage),
36
- model: response["model"] || request["model"],
37
- token_usage: token_usage(usage: usage, cache_read: cache_read),
38
- usage_source: :response,
39
- service_line_items: service_line_items(usage)
40
- )
41
- end
42
-
43
- def parse_stream(response_status:, request_body: nil, events: [], **)
44
- return nil unless response_status == 200
45
-
46
- request = safe_json_parse(request_body)
47
- model = find_event_value(events) { |data| data.dig("message", "model") } || request["model"]
48
- usage = stream_usage(events)
49
- response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
50
-
51
- if usage
52
- build_stream_result(
53
- model: model,
54
- usage: usage,
55
- response_id: response_id,
56
- pricing_mode: pricing_mode(request: request, usage: usage)
57
- )
58
- else
59
- build_unknown_stream_usage(
60
- provider: "anthropic",
61
- model: model,
62
- provider_response_id: response_id,
63
- pricing_mode: pricing_mode(request: request, usage: usage)
64
- )
65
- end
66
- end
67
-
68
- def provider_for(_request_url)
69
- "anthropic"
70
- end
71
-
72
- private
73
-
74
- def stream_usage(events)
75
- latest_delta = find_event_value(events, reverse: true) do |data|
76
- data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
77
- end
78
- return nil unless latest_delta
79
-
80
- start_usage = find_event_value(events, reverse: true) do |data|
81
- data.dig("message", "usage") if data["type"] == "message_start"
82
- end
83
-
84
- (start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
85
- delta_val || start_val
86
- end
87
- end
88
-
89
- def build_stream_result(model:, usage:, response_id:, pricing_mode:)
90
- cache_read = usage["cache_read_input_tokens"].to_i
91
-
92
- Event.build(
93
- provider: "anthropic",
94
- provider_response_id: response_id,
95
- pricing_mode: pricing_mode,
96
- model: model,
97
- token_usage: token_usage(usage: usage, cache_read: cache_read),
98
- stream: true,
99
- usage_source: :stream_final,
100
- service_line_items: service_line_items(usage)
101
- )
102
- end
103
-
104
- def service_line_items(usage)
105
- server_tool_use = usage["server_tool_use"]
106
- return [] unless server_tool_use.is_a?(Hash)
107
-
108
- Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
109
- quantity = server_tool_use[count_key.to_s].to_i
110
- next if quantity.zero?
111
-
112
- Billing::LineItem.build(
113
- component_key: component_key,
114
- quantity: quantity,
115
- cost_status: Billing::CostStatus::UNKNOWN,
116
- pricing_basis: :provider_usage,
117
- provider_field: "usage.server_tool_use.#{count_key}"
118
- )
119
- end
120
- end
121
-
122
- def token_usage(usage:, cache_read:)
123
- input = usage["input_tokens"].to_i
124
- output = usage["output_tokens"].to_i
125
- cache_creation = usage["cache_creation"]
126
- if cache_creation.is_a?(Hash)
127
- cache_write = cache_creation["ephemeral_5m_input_tokens"].to_i
128
- cache_write_extended = cache_creation["ephemeral_1h_input_tokens"].to_i
129
- else
130
- warn_unexpected_cache_creation(cache_creation, usage)
131
- cache_write = usage["cache_creation_input_tokens"].to_i
132
- cache_write_extended = 0
133
- end
134
- hidden_output = (
135
- usage["thinking_tokens"] || usage["thinking_output_tokens"] ||
136
- usage.dig("output_tokens_details", "reasoning_tokens")
137
- ).to_i
138
-
139
- TokenUsage.build(
140
- input_tokens: input,
141
- output_tokens: output,
142
- total_tokens: input + output + cache_read + cache_write + cache_write_extended,
143
- cache_read_input_tokens: cache_read,
144
- cache_write_input_tokens: cache_write,
145
- cache_write_extended_input_tokens: cache_write_extended,
146
- hidden_output_tokens: hidden_output
147
- )
148
- end
149
-
150
- def warn_unexpected_cache_creation(cache_creation, usage)
151
- return if cache_creation.nil? || usage.key?("cache_creation_input_tokens")
152
-
153
- Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
154
- end
155
-
156
- def pricing_mode(request:, usage:)
157
- modes = []
158
- speed = usage&.fetch("speed", nil) || request["speed"]
159
- service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
160
- service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
161
-
162
- modes << Pricing::Mode.normalize(speed)
163
- modes << Pricing::Mode.normalize(service_tier)
164
- geo = inference_geo(request: request, usage: usage).downcase
165
- modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
166
-
167
- modes = modes.compact.uniq
168
- modes.empty? ? nil : modes.join("_")
169
- end
170
-
171
- def inference_geo(request:, usage:)
172
- (usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
173
- end
174
- end
175
- end
176
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "openai_usage"
5
- require_relative "../providers/azure/hosts"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Azure < Base
10
- include OpenaiUsage
11
-
12
- TRACKED_ENDPOINTS = %w[
13
- chat/completions completions embeddings moderations responses
14
- audio/transcriptions audio/translations audio/speech
15
- images/generations images/edits images/variations
16
- ].freeze
17
-
18
- PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
19
-
20
- class << self
21
- def match?(url)
22
- uri_matches?(url) do |uri|
23
- LlmCostTracker::Providers::Azure::Hosts.openai?(uri.host) && uri.path.to_s.match?(PATH_PATTERN)
24
- end
25
- end
26
-
27
- def provider_names
28
- %w[azure_openai]
29
- end
30
- end
31
-
32
- def provider_for(_request_url)
33
- "azure_openai"
34
- end
35
-
36
- def model_for(request_url, request_parsed)
37
- body_model = super
38
- return body_model if body_model
39
-
40
- uri = parsed_uri(request_url)
41
- match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
42
- match && match[1]
43
- end
44
- end
45
- end
46
- end