llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,36 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "openai_service_charges"
3
+ require_relative "../providers/openai/hosts"
4
+ require_relative "../providers/openai/model_families"
5
+ require_relative "../providers/openai/service_charges"
4
6
 
5
7
  module LlmCostTracker
6
8
  module Parsers
7
9
  module OpenaiUsage
8
- include OpenaiServiceCharges
9
-
10
- OPENAI_DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
10
+ include LlmCostTracker::Providers::Openai::ServiceCharges
11
11
 
12
12
  class << self
13
13
  def combined_pricing_mode(host:, model:, service_tier:)
14
- modes = [Pricing.normalize_mode(service_tier)]
14
+ modes = [Pricing::Mode.normalize(service_tier)]
15
15
  modes << "data_residency" if regional_processing?(host: host, model: model)
16
16
  modes = modes.compact.uniq
17
17
  modes.empty? ? nil : modes.join("_")
18
18
  end
19
19
 
20
20
  def regional_processing?(host:, model:)
21
- host.to_s.downcase.match?(OPENAI_DATA_RESIDENCY_HOST_PATTERN) && data_residency_model?(model)
22
- end
23
-
24
- def data_residency_model?(model)
25
- model.to_s.match?(
26
- /\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
27
- )
21
+ LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
22
+ LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
28
23
  end
29
24
  end
30
25
 
31
- private
32
-
33
- def parse_openai_usage(request_url:, request_body:, response_status:, response_body:)
26
+ def parse(request_url:, request_body:, response_status:, response_body:, **)
34
27
  return nil unless response_status == 200
35
28
 
36
29
  response = safe_json_parse(response_body)
@@ -42,7 +35,7 @@ module LlmCostTracker
42
35
 
43
36
  model = response["model"] || request["model"]
44
37
 
45
- UsageCapture.build(
38
+ Event.build(
46
39
  provider: provider_for(request_url),
47
40
  provider_response_id: response["id"],
48
41
  pricing_mode: pricing_mode(
@@ -53,11 +46,11 @@ module LlmCostTracker
53
46
  model: model,
54
47
  token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
55
48
  usage_source: :response,
56
- service_line_items: openai_service_line_items(response, request: request)
49
+ service_line_items: service_line_items_for(response, request: request, model: response["model"])
57
50
  )
58
51
  end
59
52
 
60
- def parse_openai_stream_usage(response_status:, request_url: nil, request_body: nil, events: [])
53
+ def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
61
54
  return nil unless response_status == 200
62
55
 
63
56
  request = safe_json_parse(request_body)
@@ -70,6 +63,12 @@ module LlmCostTracker
70
63
  build_unknown_stream_usage(**context)
71
64
  end
72
65
 
66
+ def auto_enable_stream_usage?(request_url)
67
+ openai_chat_completions_url?(request_url)
68
+ end
69
+
70
+ private
71
+
73
72
  def stream_capture_context(events:, request:, request_url:)
74
73
  model = find_event_value(events) do |data|
75
74
  data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
@@ -91,7 +90,7 @@ module LlmCostTracker
91
90
 
92
91
  def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
93
92
  cache_read = cache_read_input_tokens(usage)
94
- UsageCapture.build(
93
+ Event.build(
95
94
  provider: provider,
96
95
  provider_response_id: provider_response_id,
97
96
  pricing_mode: pricing_mode,
@@ -137,9 +136,6 @@ module LlmCostTracker
137
136
  OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
138
137
  end
139
138
 
140
- IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
141
- private_constant :IMAGE_OUTPUT_MODEL_PATTERN
142
-
143
139
  def token_usage(usage:, cache_read:, model: nil)
144
140
  audio_input = audio_input_tokens(usage)
145
141
  audio_output = audio_output_tokens(usage)
@@ -150,7 +146,7 @@ module LlmCostTracker
150
146
  image_output, regular_output_remainder = split_stream_image_output(
151
147
  raw_output: raw_output, image_output_details: image_output_details,
152
148
  text_output_details: text_output_details, audio_output: audio_output,
153
- default_to_image: model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
149
+ default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
154
150
  )
155
151
 
156
152
  TokenUsage.build(
@@ -2,19 +2,44 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Parsers
5
- BUILT_INS = [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new].freeze
5
+ autoload :Base, "llm_cost_tracker/parsers/base"
6
+ autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
7
+ autoload :Openai, "llm_cost_tracker/parsers/openai"
8
+ autoload :Azure, "llm_cost_tracker/parsers/azure"
9
+ autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
10
+ autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
11
+ autoload :Gemini, "llm_cost_tracker/parsers/gemini"
12
+
13
+ MUTEX = Mutex.new
14
+ PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
6
15
 
7
16
  module_function
8
17
 
9
18
  def find_for(url)
10
- BUILT_INS.find { |parser| parser.match?(url) }
19
+ PARSER_CONSTANTS.each do |name|
20
+ klass = const_get(name)
21
+ return instance_for(klass) if klass.match?(url)
22
+ end
23
+ nil
11
24
  end
12
25
 
13
26
  def find_for_provider(provider)
14
27
  provider_name = provider.to_s.downcase
15
- BUILT_INS.find do |parser|
16
- parser.provider_names.include?(provider_name)
28
+ PARSER_CONSTANTS.each do |name|
29
+ klass = const_get(name)
30
+ return instance_for(klass) if klass.provider_names.include?(provider_name)
31
+ end
32
+ nil
33
+ end
34
+
35
+ def instance_for(klass)
36
+ cached = (@instances ||= {})[klass]
37
+ return cached if cached
38
+
39
+ MUTEX.synchronize do
40
+ @instances[klass] ||= klass.new
17
41
  end
18
42
  end
43
+ private_class_method :instance_for
19
44
  end
20
45
  end