llm_cost_tracker 0.10.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +4 -1
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
  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 +15 -3
  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/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
  17. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  25. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  26. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  27. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  28. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  33. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  34. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  36. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  37. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  38. data/config/routes.rb +1 -0
  39. data/lib/llm_cost_tracker/budget.rb +1 -2
  40. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
  43. data/lib/llm_cost_tracker/configuration.rb +3 -20
  44. data/lib/llm_cost_tracker/doctor.rb +74 -8
  45. data/lib/llm_cost_tracker/event.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
  49. data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
  50. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
  51. data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
  52. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
  53. data/lib/llm_cost_tracker/ledger.rb +13 -0
  54. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
  55. data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
  56. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
  57. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
  58. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  59. data/lib/llm_cost_tracker/parsers.rb +0 -2
  60. data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
  61. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  62. data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
  63. data/lib/llm_cost_tracker/pricing.rb +1 -18
  64. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  65. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  66. data/lib/llm_cost_tracker/railtie.rb +0 -4
  67. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  68. data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
  69. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
  70. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  71. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  72. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  73. data/lib/llm_cost_tracker/tracker.rb +1 -1
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. metadata +14 -6
  76. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  77. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  78. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  79. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../billing/line_item"
4
+ require_relative "model_families"
5
+
6
+ module LlmCostTracker
7
+ module Providers
8
+ module Openai
9
+ module ServiceCharges
10
+ RESPONSE_OUTPUT_COMPONENTS = {
11
+ "web_search_call" => :web_search_request,
12
+ "file_search_call" => :file_search_call,
13
+ "code_interpreter_call" => :container_session,
14
+ "mcp_call" => :mcp_call
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def line_items_from_output(output_items, request: nil, model: nil)
20
+ deduped = {}
21
+ Array(output_items).each { |item| store_output_item(deduped, item) }
22
+ deduped.values
23
+ .select { |item| billable?(item) }
24
+ .filter_map { |item| build_line_item(item, request: request, model: model) }
25
+ end
26
+
27
+ def service_line_items_for(response, request: nil, model: nil)
28
+ output_items = Array(response["output"])
29
+ output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
30
+ line_items_from_output(output_items, request: request, model: model)
31
+ end
32
+
33
+ CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
34
+ CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
35
+
36
+ def chat_completions_web_search_items(response, model: nil)
37
+ return [] unless response["choices"]
38
+
39
+ provider_field = chat_completions_search_provider_field(response["choices"], model)
40
+ return [] unless provider_field
41
+
42
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
43
+ "provider_field" => provider_field }]
44
+ end
45
+
46
+ def chat_completions_search_provider_field(choices, model)
47
+ return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
48
+ return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
49
+
50
+ nil
51
+ end
52
+
53
+ def chat_completions_used_web_search?(choices)
54
+ Array(choices).any? do |choice|
55
+ Array(choice.dig("message", "annotations")).any? do |annotation|
56
+ annotation.is_a?(Hash) && annotation["type"] == "url_citation"
57
+ end
58
+ end
59
+ end
60
+
61
+ def billable?(item)
62
+ return false unless item.is_a?(Hash)
63
+
64
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
65
+ return false unless component
66
+ return true unless component == :web_search_request
67
+
68
+ action_type = item.dig("action", "type")
69
+ action_type.nil? || action_type == "search"
70
+ end
71
+
72
+ def store_output_item(output_items, item)
73
+ return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
74
+
75
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
76
+ key = if component == :container_session && item["container_id"]
77
+ "#{component}:#{item['container_id']}"
78
+ else
79
+ item["id"] || "#{item['type']}:#{output_items.length}"
80
+ end
81
+ output_items[key] = item
82
+ end
83
+
84
+ def build_line_item(item, request: nil, model: nil)
85
+ return nil unless item.is_a?(Hash)
86
+
87
+ component_key = component_key_for(item, request: request, model: model)
88
+ return nil unless component_key
89
+
90
+ provider_item_id = if component_key == :container_session
91
+ item["container_id"] || item["id"]
92
+ else
93
+ item["id"]
94
+ end
95
+ Billing::LineItem.build(
96
+ component_key: component_key,
97
+ quantity: 1,
98
+ cost_status: Billing::CostStatus::UNKNOWN,
99
+ pricing_basis: :provider_usage,
100
+ provider_field: item["provider_field"] || "response.output.#{item['type']}",
101
+ provider_item_id: provider_item_id,
102
+ details: line_item_details(item)
103
+ )
104
+ end
105
+
106
+ def component_key_for(item, request:, model:)
107
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
108
+ return component unless component == :web_search_request
109
+ return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
110
+
111
+ reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
112
+ end
113
+
114
+ def web_search_preview_used?(request)
115
+ tools = request && (request[:tools] || request["tools"])
116
+ return false unless tools.respond_to?(:each)
117
+
118
+ tools.any? do |tool|
119
+ type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
120
+ type.to_s.include?("web_search_preview")
121
+ end
122
+ end
123
+
124
+ def chat_completions_search_model?(model)
125
+ return false unless model
126
+
127
+ name = model.to_s.split("/", 2).last
128
+ ModelFamilies.chat_completions_search?(name)
129
+ end
130
+
131
+ def reasoning_model?(model)
132
+ return false unless model
133
+
134
+ name = model.to_s.split("/", 2).last
135
+ ModelFamilies.reasoning?(name)
136
+ end
137
+
138
+ def line_item_details(item)
139
+ {
140
+ "status" => item["status"],
141
+ "action_type" => item.dig("action", "type"),
142
+ "container_id" => item["container_id"]
143
+ }.compact
144
+ end
145
+
146
+ def openai_stream_service_line_items(events, request: nil, model: nil)
147
+ output_items = []
148
+ each_event_data(events) do |data|
149
+ output_items.concat(Array(data.dig("response", "output")))
150
+ output_items << data["item"] if data["item"]
151
+ end
152
+ line_items_from_output(output_items, request: request, model: model)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -20,9 +20,5 @@ module LlmCostTracker
20
20
  require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
21
21
  require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
22
22
  end
23
-
24
- rake_tasks do
25
- load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
26
- end
27
23
  end
28
24
  end
@@ -85,38 +85,35 @@ module LlmCostTracker
85
85
  end
86
86
 
87
87
  def print_unmatched_provider_rows(diff, output)
88
- return if diff.unmatched_provider_rows.empty?
89
-
90
- output.puts " unmatched provider rows#{truncation_suffix(diff.unmatched_provider_rows.size,
91
- diff.unmatched_provider_rows_total)}:"
92
- diff.unmatched_provider_rows.each do |row|
93
- output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
94
- "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
88
+ print_section(output, "unmatched provider rows",
89
+ diff.unmatched_provider_rows, diff.unmatched_provider_rows_total) do |row|
90
+ "#{row[:external_id]} (#{row[:match_basis]}): " \
91
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
95
92
  end
96
93
  end
97
94
 
98
95
  def print_unmatched_local_calls(diff, output)
99
- return if diff.unmatched_local_calls.empty?
100
-
101
- output.puts " unmatched local calls#{truncation_suffix(diff.unmatched_local_calls.size,
102
- diff.unmatched_local_calls_total)}:"
103
- diff.unmatched_local_calls.each do |row|
104
- output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
105
- "#{format_attribution(row[:attribution])}"
96
+ print_section(output, "unmatched local calls",
97
+ diff.unmatched_local_calls, diff.unmatched_local_calls_total) do |row|
98
+ "#{row[:count]} calls / #{row[:total_cost].to_s('F')} #{format_attribution(row[:attribution])}"
106
99
  end
107
100
  end
108
101
 
109
102
  def print_non_cost_rows(diff, output)
110
- return if diff.non_cost_rows.empty?
111
-
112
- output.puts " non-cost evidence#{truncation_suffix(diff.non_cost_rows.size,
113
- diff.non_cost_rows_total)}:"
114
- diff.non_cost_rows.each do |row|
115
- output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
116
- "#{format_attribution(row[:attribution])}"
103
+ print_section(output, "non-cost evidence",
104
+ diff.non_cost_rows, diff.non_cost_rows_total) do |row|
105
+ "[#{row[:row_type]}/#{row[:meter]}] " \
106
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
117
107
  end
118
108
  end
119
109
 
110
+ def print_section(output, label, rows, total)
111
+ return if rows.empty?
112
+
113
+ output.puts " #{label}#{truncation_suffix(rows.size, total)}:"
114
+ rows.each { |row| output.puts " #{yield(row)}" }
115
+ end
116
+
120
117
  def truncation_suffix(shown, total)
121
118
  return "" if shown >= total
122
119
 
@@ -5,6 +5,7 @@ require "date"
5
5
  require "json"
6
6
 
7
7
  require_relative "import_result"
8
+ require_relative "sources/coercion"
8
9
  require_relative "../ledger/rollups"
9
10
 
10
11
  module LlmCostTracker
@@ -120,7 +121,7 @@ module LlmCostTracker
120
121
  def normalize_rows(rows)
121
122
  errors = []
122
123
  normalized = rows.each_with_index.filter_map do |row, index|
123
- attrs = symbolize(row)
124
+ attrs = Sources::Coercion.symbolize(row)
124
125
  missing = REQUIRED_FIELDS - attrs.keys
125
126
  if missing.any?
126
127
  errors << "row #{index}: missing #{missing.join(', ')}"
@@ -213,12 +214,6 @@ module LlmCostTracker
213
214
  raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
214
215
  end
215
216
 
216
- def symbolize(row)
217
- return row if row.is_a?(Hash) && row.keys.all?(Symbol)
218
-
219
- row.to_h.transform_keys { |key| key.to_s.to_sym }
220
- end
221
-
222
217
  def parse_date(value)
223
218
  return value if value.is_a?(Date)
224
219
 
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bigdecimal"
4
- require "json"
5
4
  require "time"
6
5
 
6
+ require_relative "coercion"
7
7
  require_relative "fingerprint"
8
8
  require_relative "../../providers/anthropic/tier_classification"
9
9
 
@@ -23,7 +23,7 @@ module LlmCostTracker
23
23
  module_function
24
24
 
25
25
  def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
26
- payload = coerce_hash(response)
26
+ payload = Coercion.coerce_hash(response, label: "Anthropic Usage")
27
27
  buckets = Array(payload[:data])
28
28
  buckets.flat_map do |bucket|
29
29
  rows_for_bucket(bucket, authority: authority, row_type: row_type)
@@ -31,7 +31,7 @@ module LlmCostTracker
31
31
  end
32
32
 
33
33
  def rows_for_bucket(bucket, authority:, row_type:)
34
- bucket = symbolize(bucket)
34
+ bucket = Coercion.symbolize(bucket)
35
35
  starting_at = bucket[:starting_at]
36
36
  ending_at = bucket[:ending_at]
37
37
  return [] unless starting_at && ending_at
@@ -50,7 +50,7 @@ module LlmCostTracker
50
50
  end
51
51
 
52
52
  def row_for_result(raw, period_start:, period_end:, starting_at:, ending_at:, authority:, row_type:)
53
- result = symbolize(raw)
53
+ result = Coercion.symbolize(raw)
54
54
  raw_amount = result[:amount]
55
55
  return nil if raw_amount.nil?
56
56
 
@@ -122,19 +122,11 @@ module LlmCostTracker
122
122
  end
123
123
 
124
124
  def fingerprint_for(result, starting_at:, ending_at:)
125
- attributes = result.merge(starting_at: normalized_epoch(starting_at),
126
- ending_at: normalized_epoch(ending_at))
125
+ attributes = result.merge(starting_at: Coercion.normalized_epoch(starting_at),
126
+ ending_at: Coercion.normalized_epoch(ending_at))
127
127
  Fingerprint.compute(FINGERPRINT_KEYS, attributes)
128
128
  end
129
129
 
130
- def normalized_epoch(value)
131
- return value.to_i if value.is_a?(Numeric)
132
-
133
- Time.parse(value.to_s).utc.to_i
134
- rescue ArgumentError
135
- value.to_s
136
- end
137
-
138
130
  def parse_date(value)
139
131
  return value if value.is_a?(Date)
140
132
  return Time.at(value).utc.to_date if value.is_a?(Numeric)
@@ -150,22 +142,6 @@ module LlmCostTracker
150
142
  end
151
143
  (time - 1).utc.to_date
152
144
  end
153
-
154
- def coerce_hash(response)
155
- return {} if response.nil?
156
- return symbolize(response) if response.is_a?(Hash)
157
-
158
- parsed = JSON.parse(response.to_s)
159
- raise ArgumentError, "Anthropic Usage payload must be a JSON object" unless parsed.is_a?(Hash)
160
-
161
- symbolize(parsed)
162
- rescue JSON::ParserError => e
163
- raise ArgumentError, "Unable to parse Anthropic Usage payload: #{e.message}"
164
- end
165
-
166
- def symbolize(hash)
167
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
168
- end
169
145
  end
170
146
  end
171
147
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module LlmCostTracker
7
+ module Reconciliation
8
+ module Sources
9
+ module Coercion
10
+ module_function
11
+
12
+ def symbolize(hash)
13
+ return hash if hash.is_a?(Hash) && hash.keys.all?(Symbol)
14
+
15
+ hash.to_h.transform_keys { |key| key.to_s.to_sym }
16
+ end
17
+
18
+ def normalized_epoch(value)
19
+ return value.to_i if value.is_a?(Numeric)
20
+
21
+ Time.parse(value.to_s).utc.to_i
22
+ rescue ArgumentError
23
+ value.to_s
24
+ end
25
+
26
+ def coerce_hash(response, label:)
27
+ return {} if response.nil?
28
+ return symbolize(response) if response.is_a?(Hash)
29
+
30
+ parsed = JSON.parse(response.to_s)
31
+ raise ArgumentError, "#{label} payload must be a JSON object" unless parsed.is_a?(Hash)
32
+
33
+ symbolize(parsed)
34
+ rescue JSON::ParserError => e
35
+ raise ArgumentError, "Unable to parse #{label} payload: #{e.message}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "time"
5
4
 
5
+ require_relative "coercion"
6
6
  require_relative "fingerprint"
7
7
 
8
8
  module LlmCostTracker
@@ -17,7 +17,7 @@ module LlmCostTracker
17
17
  module_function
18
18
 
19
19
  def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
20
- payload = coerce_hash(response)
20
+ payload = Coercion.coerce_hash(response, label: "OpenAI Costs")
21
21
  buckets = Array(payload[:data])
22
22
  buckets.flat_map do |bucket|
23
23
  rows_for_bucket(bucket, authority: authority, row_type: row_type)
@@ -25,7 +25,7 @@ module LlmCostTracker
25
25
  end
26
26
 
27
27
  def rows_for_bucket(bucket, authority:, row_type:)
28
- bucket = symbolize(bucket)
28
+ bucket = Coercion.symbolize(bucket)
29
29
  start_time = bucket[:start_time]
30
30
  end_time = bucket[:end_time]
31
31
  return [] unless start_time && end_time
@@ -44,8 +44,8 @@ module LlmCostTracker
44
44
  end
45
45
 
46
46
  def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
47
- result = symbolize(raw)
48
- amount = symbolize(result[:amount] || {})
47
+ result = Coercion.symbolize(raw)
48
+ amount = Coercion.symbolize(result[:amount] || {})
49
49
  billed_amount = amount[:value]
50
50
  return nil if billed_amount.nil?
51
51
 
@@ -93,19 +93,11 @@ module LlmCostTracker
93
93
  end
94
94
 
95
95
  def fingerprint_for(result, start_time:, end_time:)
96
- attributes = result.merge(start_time: normalized_epoch(start_time),
97
- end_time: normalized_epoch(end_time))
96
+ attributes = result.merge(start_time: Coercion.normalized_epoch(start_time),
97
+ end_time: Coercion.normalized_epoch(end_time))
98
98
  Fingerprint.compute(FINGERPRINT_KEYS, attributes)
99
99
  end
100
100
 
101
- def normalized_epoch(value)
102
- return value.to_i if value.is_a?(Numeric)
103
-
104
- Time.parse(value.to_s).utc.to_i
105
- rescue ArgumentError
106
- value.to_s
107
- end
108
-
109
101
  def epoch_to_date(value)
110
102
  return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
111
103
 
@@ -120,22 +112,6 @@ module LlmCostTracker
120
112
  end
121
113
  (time - 1).utc.to_date
122
114
  end
123
-
124
- def coerce_hash(response)
125
- return {} if response.nil?
126
- return symbolize(response) if response.is_a?(Hash)
127
-
128
- parsed = JSON.parse(response.to_s)
129
- raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)
130
-
131
- symbolize(parsed)
132
- rescue JSON::ParserError => e
133
- raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
134
- end
135
-
136
- def symbolize(hash)
137
- hash.to_h.transform_keys { |key| key.to_s.to_sym }
138
- end
139
115
  end
140
116
  end
141
117
  end
@@ -4,18 +4,18 @@ module LlmCostTracker
4
4
  class Report
5
5
  class Formatter
6
6
  TOP_LIMIT = 5
7
- NAME_COLUMN_WIDTH = 28
8
- TOP_CALL_COLUMN_WIDTH = 32
7
+ MIN_COLUMN_WIDTH = 28
9
8
 
10
- def initialize(data)
9
+ def initialize(data, color: $stdout.tty?)
11
10
  @data = data
11
+ @color = color
12
12
  end
13
13
 
14
14
  def to_s
15
- lines = ["LLM Cost Report (last #{@data.days} days)", ""]
15
+ lines = [bold("LLM Cost Report (last #{@data.days} days)"), ""]
16
16
  append_summary(lines)
17
- append_cost_section(lines, "By provider", @data.cost_by_provider)
18
- append_cost_section(lines, "By model", @data.cost_by_model)
17
+ append_cost_section(lines, "By provider", @data.cost_by_provider) { |row| row.name.to_s }
18
+ append_cost_section(lines, "By model", @data.cost_by_model) { |row| row.name.to_s }
19
19
  append_tag_sections(lines)
20
20
  append_top_calls(lines)
21
21
  lines.join("\n")
@@ -27,36 +27,37 @@ module LlmCostTracker
27
27
  lines << "Total cost: #{money(@data.total_cost)}"
28
28
  lines << "Requests: #{@data.requests_count}"
29
29
  lines << "Avg latency: #{average_latency}"
30
- lines << "Unknown pricing: #{@data.unknown_pricing_count}"
30
+ lines << "Unknown pricing: #{colored_unknown_pricing(@data.unknown_pricing_count)}"
31
31
  end
32
32
 
33
- def append_cost_section(lines, title, rows)
33
+ def append_cost_section(lines, title, rows, &name_for)
34
34
  lines << ""
35
- lines << "#{title}:"
35
+ lines << bold("#{title}:")
36
36
  return lines << " none" if rows.empty?
37
37
 
38
- rows.first(TOP_LIMIT).each do |row|
39
- lines << " #{row.name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(row.total_cost)}"
38
+ visible = rows.first(TOP_LIMIT)
39
+ width = column_width(visible, &name_for)
40
+ visible.each do |row|
41
+ lines << " #{name_for.call(row).ljust(width)} #{money(row.total_cost)}"
40
42
  end
41
43
  end
42
44
 
43
45
  def append_tag_sections(lines)
44
46
  @data.cost_by_tags.each do |tag_key, rows|
45
- append_cost_section(lines, "By tag (#{tag_key})", rows)
47
+ append_cost_section(lines, "By tag (#{tag_key})", rows) { |row| row.name.to_s }
46
48
  end
47
49
  end
48
50
 
49
51
  def append_top_calls(lines)
50
- lines << ""
51
- lines << "Top expensive calls:"
52
- return lines << " none" if @data.top_calls.empty?
53
-
54
- @data.top_calls.first(TOP_LIMIT).each do |call|
55
- label = "#{call.provider}/#{call.model}"
56
- lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
52
+ append_cost_section(lines, "Top expensive calls", @data.top_calls) do |call|
53
+ "#{call.provider}/#{call.model}"
57
54
  end
58
55
  end
59
56
 
57
+ def column_width(rows, &name_for)
58
+ [MIN_COLUMN_WIDTH, rows.map { |row| name_for.call(row).length }.max.to_i].max
59
+ end
60
+
60
61
  def average_latency
61
62
  @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
62
63
  end
@@ -64,6 +65,18 @@ module LlmCostTracker
64
65
  def money(value)
65
66
  "$#{format('%.6f', value.to_f)}"
66
67
  end
68
+
69
+ def colored_unknown_pricing(count)
70
+ return count.to_s unless @color
71
+
72
+ count.to_i.positive? ? "\e[33m#{count}\e[0m" : "\e[32m#{count}\e[0m"
73
+ end
74
+
75
+ def bold(text)
76
+ return text unless @color
77
+
78
+ "\e[1m#{text}\e[0m"
79
+ end
67
80
  end
68
81
  end
69
82
  end
@@ -23,7 +23,7 @@ module LlmCostTracker
23
23
  def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
24
24
  return unless LlmCostTracker.configuration.enabled
25
25
 
26
- pricing_mode = Pricing.normalize_mode(pricing_mode) || event.pricing_mode
26
+ pricing_mode = Pricing::Mode.normalize(pricing_mode) || event.pricing_mode
27
27
  cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
28
28
  provider: event.provider,
29
29
  model: event.model,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko
@@ -239,6 +239,7 @@ files:
239
239
  - app/controllers/llm_cost_tracker/dashboard_controller.rb
240
240
  - app/controllers/llm_cost_tracker/data_quality_controller.rb
241
241
  - app/controllers/llm_cost_tracker/models_controller.rb
242
+ - app/controllers/llm_cost_tracker/pricing_controller.rb
242
243
  - app/controllers/llm_cost_tracker/reconciliation_controller.rb
243
244
  - app/controllers/llm_cost_tracker/tags_controller.rb
244
245
  - app/helpers/llm_cost_tracker/application_helper.rb
@@ -249,6 +250,7 @@ files:
249
250
  - app/helpers/llm_cost_tracker/inline_style_helper.rb
250
251
  - app/helpers/llm_cost_tracker/pagination_helper.rb
251
252
  - app/helpers/llm_cost_tracker/reconciliation_helper.rb
253
+ - app/helpers/llm_cost_tracker/sortable_table_helper.rb
252
254
  - app/helpers/llm_cost_tracker/token_usage_helper.rb
253
255
  - app/models/llm_cost_tracker/call.rb
254
256
  - app/models/llm_cost_tracker/call_line_item.rb
@@ -264,8 +266,10 @@ files:
264
266
  - app/services/llm_cost_tracker/dashboard/overview_stats.rb
265
267
  - app/services/llm_cost_tracker/dashboard/pagination.rb
266
268
  - app/services/llm_cost_tracker/dashboard/params.rb
269
+ - app/services/llm_cost_tracker/dashboard/pricing_overview.rb
267
270
  - app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
268
271
  - app/services/llm_cost_tracker/dashboard/setup_state.rb
272
+ - app/services/llm_cost_tracker/dashboard/sort.rb
269
273
  - app/services/llm_cost_tracker/dashboard/spend_anomaly.rb
270
274
  - app/services/llm_cost_tracker/dashboard/tag_breakdown.rb
271
275
  - app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb
@@ -280,12 +284,14 @@ files:
280
284
  - app/views/llm_cost_tracker/errors/invalid_filter.html.erb
281
285
  - app/views/llm_cost_tracker/errors/not_found.html.erb
282
286
  - app/views/llm_cost_tracker/models/index.html.erb
287
+ - app/views/llm_cost_tracker/pricing/index.html.erb
283
288
  - app/views/llm_cost_tracker/reconciliation/index.html.erb
284
- - app/views/llm_cost_tracker/shared/_active_filters.html.erb
285
289
  - app/views/llm_cost_tracker/shared/_bar.html.erb
286
- - app/views/llm_cost_tracker/shared/_filters.html.erb
290
+ - app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb
291
+ - app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb
292
+ - app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb
293
+ - app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb
287
294
  - app/views/llm_cost_tracker/shared/_metric_stack.html.erb
288
- - app/views/llm_cost_tracker/shared/_sort.html.erb
289
295
  - app/views/llm_cost_tracker/shared/_spend_chart.html.erb
290
296
  - app/views/llm_cost_tracker/shared/_tag_chips.html.erb
291
297
  - app/views/llm_cost_tracker/shared/setup_required.html.erb
@@ -299,6 +305,7 @@ files:
299
305
  - lib/llm_cost_tracker/billing/cost_status.rb
300
306
  - lib/llm_cost_tracker/billing/line_item.rb
301
307
  - lib/llm_cost_tracker/budget.rb
308
+ - lib/llm_cost_tracker/capture/sse.rb
302
309
  - lib/llm_cost_tracker/capture/stream.rb
303
310
  - lib/llm_cost_tracker/capture/stream_collector.rb
304
311
  - lib/llm_cost_tracker/capture/stream_tracker.rb
@@ -377,9 +384,7 @@ files:
377
384
  - lib/llm_cost_tracker/parsers/gemini.rb
378
385
  - lib/llm_cost_tracker/parsers/openai.rb
379
386
  - lib/llm_cost_tracker/parsers/openai_compatible.rb
380
- - lib/llm_cost_tracker/parsers/openai_service_charges.rb
381
387
  - lib/llm_cost_tracker/parsers/openai_usage.rb
382
- - lib/llm_cost_tracker/parsers/sse.rb
383
388
  - lib/llm_cost_tracker/prices.json
384
389
  - lib/llm_cost_tracker/pricing.rb
385
390
  - lib/llm_cost_tracker/pricing/backfill.rb
@@ -396,11 +401,13 @@ files:
396
401
  - lib/llm_cost_tracker/pricing/sync/registry_diff.rb
397
402
  - lib/llm_cost_tracker/pricing/sync/registry_writer.rb
398
403
  - lib/llm_cost_tracker/pricing/unknown.rb
404
+ - lib/llm_cost_tracker/providers/anthropic/server_tools.rb
399
405
  - lib/llm_cost_tracker/providers/anthropic/tier_classification.rb
400
406
  - lib/llm_cost_tracker/providers/azure/hosts.rb
401
407
  - lib/llm_cost_tracker/providers/gemini/model_families.rb
402
408
  - lib/llm_cost_tracker/providers/openai/hosts.rb
403
409
  - lib/llm_cost_tracker/providers/openai/model_families.rb
410
+ - lib/llm_cost_tracker/providers/openai/service_charges.rb
404
411
  - lib/llm_cost_tracker/railtie.rb
405
412
  - lib/llm_cost_tracker/reconcile_tasks.rb
406
413
  - lib/llm_cost_tracker/reconciliation.rb
@@ -409,6 +416,7 @@ files:
409
416
  - lib/llm_cost_tracker/reconciliation/import_result.rb
410
417
  - lib/llm_cost_tracker/reconciliation/importer.rb
411
418
  - lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb
419
+ - lib/llm_cost_tracker/reconciliation/sources/coercion.rb
412
420
  - lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb
413
421
  - lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb
414
422
  - lib/llm_cost_tracker/report.rb