llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -21,12 +21,22 @@ module LlmCostTracker
21
21
  ].uniq.freeze
22
22
  end
23
23
 
24
- def parse(request_url, request_body, response_status, response_body)
25
- parse_openai_usage(request_url, request_body, response_status, response_body)
24
+ def parse(request_url:, request_body:, response_status:, response_body:, **)
25
+ parse_openai_usage(
26
+ request_url: request_url,
27
+ request_body: request_body,
28
+ response_status: response_status,
29
+ response_body: response_body
30
+ )
26
31
  end
27
32
 
28
- def parse_stream(request_url, request_body, response_status, events)
29
- parse_openai_stream_usage(request_url, request_body, response_status, events)
33
+ def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
34
+ parse_openai_stream_usage(
35
+ request_url: request_url,
36
+ request_body: request_body,
37
+ response_status: response_status,
38
+ events: events
39
+ )
30
40
  end
31
41
 
32
42
  private
@@ -5,7 +5,7 @@ module LlmCostTracker
5
5
  module OpenaiUsage
6
6
  private
7
7
 
8
- def parse_openai_usage(request_url, request_body, response_status, response_body)
8
+ def parse_openai_usage(request_url:, request_body:, response_status:, response_body:)
9
9
  return nil unless response_status == 200
10
10
 
11
11
  response = safe_json_parse(response_body)
@@ -15,38 +15,44 @@ module LlmCostTracker
15
15
  request = safe_json_parse(request_body)
16
16
  cache_read = cache_read_input_tokens(usage)
17
17
 
18
- ParsedUsage.build(
18
+ model = response["model"] || request["model"]
19
+
20
+ UsageCapture.build(
19
21
  provider: provider_for(request_url),
20
22
  provider_response_id: response["id"],
21
- model: response["model"] || request["model"],
22
- input_tokens: regular_input_tokens(usage, cache_read),
23
- output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
24
- total_tokens: total_tokens(usage, cache_read),
25
- cache_read_input_tokens: cache_read,
26
- hidden_output_tokens: hidden_output_tokens(usage),
23
+ pricing_mode: pricing_mode(
24
+ request_url: request_url,
25
+ model: model,
26
+ service_tier: response["service_tier"] || request["service_tier"]
27
+ ),
28
+ model: model,
29
+ token_usage: token_usage(usage: usage, cache_read: cache_read),
27
30
  usage_source: :response
28
31
  )
29
32
  end
30
33
 
31
- def parse_openai_stream_usage(request_url, request_body, response_status, events)
34
+ def parse_openai_stream_usage(response_status:, request_url: nil, request_body: nil, events: [])
32
35
  return nil unless response_status == 200
33
36
 
34
37
  request = safe_json_parse(request_body)
35
- model = detect_stream_model(events) || request["model"]
38
+ model =
39
+ find_event_value(events) { |data| data["model"] || data.dig("response", "model") } || request["model"]
36
40
  usage = detect_stream_usage(events)
37
- response_id = detect_stream_response_id(events)
41
+ response_id = find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
42
+ pricing_mode = pricing_mode(
43
+ request_url: request_url,
44
+ model: model,
45
+ service_tier: stream_pricing_mode(events) || request["service_tier"]
46
+ )
38
47
 
39
48
  if usage
40
49
  cache_read = cache_read_input_tokens(usage)
41
- ParsedUsage.build(
50
+ UsageCapture.build(
42
51
  provider: provider_for(request_url),
43
52
  provider_response_id: response_id,
53
+ pricing_mode: pricing_mode,
44
54
  model: model,
45
- input_tokens: regular_input_tokens(usage, cache_read),
46
- output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
47
- total_tokens: total_tokens(usage, cache_read),
48
- cache_read_input_tokens: cache_read,
49
- hidden_output_tokens: hidden_output_tokens(usage),
55
+ token_usage: token_usage(usage: usage, cache_read: cache_read),
50
56
  stream: true,
51
57
  usage_source: :stream_final
52
58
  )
@@ -54,7 +60,8 @@ module LlmCostTracker
54
60
  build_unknown_stream_usage(
55
61
  provider: provider_for(request_url),
56
62
  model: model,
57
- provider_response_id: response_id
63
+ provider_response_id: response_id,
64
+ pricing_mode: pricing_mode
58
65
  )
59
66
  end
60
67
  end
@@ -66,15 +73,41 @@ module LlmCostTracker
66
73
  end
67
74
  end
68
75
 
69
- def detect_stream_model(events)
70
- find_event_value(events) { |data| data["model"] || data.dig("response", "model") }
76
+ def stream_pricing_mode(events)
77
+ find_event_value(events, reverse: true) do |data|
78
+ data["service_tier"] || data.dig("response", "service_tier")
79
+ end
80
+ end
81
+
82
+ def pricing_mode(request_url:, model:, service_tier:)
83
+ modes = [Pricing.normalize_mode(service_tier)]
84
+ modes << "data_residency" if openai_regional_processing?(request_url: request_url, model: model)
85
+ modes = modes.compact.uniq
86
+ modes.empty? ? nil : modes.join("_")
71
87
  end
72
88
 
73
- def detect_stream_response_id(events)
74
- find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
89
+ def openai_regional_processing?(request_url:, model:)
90
+ uri = parsed_uri(request_url)
91
+ return false unless %w[us.api.openai.com eu.api.openai.com].include?(uri&.host.to_s.downcase)
92
+
93
+ openai_data_residency_model?(model)
94
+ end
95
+
96
+ def openai_data_residency_model?(model)
97
+ model.to_s.match?(/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro))?(?:-\d{4}-\d{2}-\d{2})?\z/)
98
+ end
99
+
100
+ def token_usage(usage:, cache_read:)
101
+ TokenUsage.build(
102
+ input_tokens: regular_input_tokens(usage: usage, cache_read: cache_read),
103
+ output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
104
+ total_tokens: total_tokens(usage: usage, cache_read: cache_read),
105
+ cache_read_input_tokens: cache_read,
106
+ hidden_output_tokens: hidden_output_tokens(usage)
107
+ )
75
108
  end
76
109
 
77
- def regular_input_tokens(usage, cache_read)
110
+ def regular_input_tokens(usage:, cache_read:)
78
111
  [(usage["prompt_tokens"] || usage["input_tokens"]).to_i - cache_read.to_i, 0].max
79
112
  end
80
113
 
@@ -88,11 +121,11 @@ module LlmCostTracker
88
121
  details["reasoning_tokens"]
89
122
  end
90
123
 
91
- def total_tokens(usage, cache_read)
124
+ def total_tokens(usage:, cache_read:)
92
125
  total = usage["total_tokens"]
93
126
  return total.to_i unless total.nil?
94
127
 
95
- regular_input_tokens(usage, cache_read) +
128
+ regular_input_tokens(usage: usage, cache_read: cache_read) +
96
129
  cache_read.to_i +
97
130
  (usage["completion_tokens"] || usage["output_tokens"]).to_i
98
131
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/object/blank"
3
4
  require "json"
4
5
 
5
6
  module LlmCostTracker
@@ -9,9 +10,9 @@ module LlmCostTracker
9
10
 
10
11
  class << self
11
12
  def parse(body)
12
- return [] if body.nil? || body.empty?
13
+ return [] if body.blank?
13
14
 
14
- return parse_json_array(body) if probably_json_array?(body)
15
+ return parse_json_array(body) if body.lstrip.start_with?("[")
15
16
 
16
17
  parse_event_stream(body)
17
18
  end
@@ -65,16 +66,12 @@ module LlmCostTracker
65
66
  end
66
67
 
67
68
  def decode_data(payload)
68
- return payload if payload.empty?
69
+ return payload if payload.blank?
69
70
 
70
71
  JSON.parse(payload)
71
72
  rescue JSON::ParserError
72
73
  payload
73
74
  end
74
-
75
- def probably_json_array?(body)
76
- body.lstrip.start_with?("[")
77
- end
78
75
  end
79
76
  end
80
77
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Parsers
5
+ BUILT_INS = [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new].freeze
6
+
7
+ module_function
8
+
9
+ def find_for(url)
10
+ BUILT_INS.find { |parser| parser.match?(url) }
11
+ end
12
+
13
+ def find_for_provider(provider)
14
+ provider_name = provider.to_s.downcase
15
+ BUILT_INS.find do |parser|
16
+ Array(parser.provider_names).map { |name| name.to_s.downcase }.include?(provider_name)
17
+ end
18
+ end
19
+ end
20
+ end