llm_cost_tracker 0.8.0 → 0.9.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -8,8 +8,10 @@ module LlmCostTracker
8
8
  cache_write_input_tokens: "Cache write",
9
9
  cache_write_extended_input_tokens: "Extended cache write",
10
10
  audio_input_tokens: "Audio input",
11
+ image_input_tokens: "Image input",
11
12
  output_tokens: "Output",
12
13
  audio_output_tokens: "Audio output",
14
+ image_output_tokens: "Image output",
13
15
  hidden_output_tokens: "Hidden output"
14
16
  }.freeze
15
17
  QUALITY_LABELS = COMPONENT_LABELS.merge(
@@ -24,8 +26,10 @@ module LlmCostTracker
24
26
  cache_write_input_tokens: "lct-stack-fill-cache-write",
25
27
  cache_write_extended_input_tokens: "lct-stack-fill-cache-write-extended",
26
28
  audio_input_tokens: "lct-stack-fill-audio-input",
29
+ image_input_tokens: "lct-stack-fill-image-input",
27
30
  output_tokens: "lct-stack-fill-output",
28
- audio_output_tokens: "lct-stack-fill-audio-output"
31
+ audio_output_tokens: "lct-stack-fill-audio-output",
32
+ image_output_tokens: "lct-stack-fill-image-output"
29
33
  }.freeze
30
34
 
31
35
  def token_usage_stack_components
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
3
  require "securerandom"
5
4
 
6
5
  require "llm_cost_tracker/billing/cost_status"
@@ -9,8 +8,6 @@ require "llm_cost_tracker/ledger/tags/sql"
9
8
 
10
9
  module LlmCostTracker
11
10
  class Call < ActiveRecord::Base
12
- self.table_name = "llm_cost_tracker_calls"
13
-
14
11
  before_validation :assign_event_id
15
12
 
16
13
  PERIOD_FORMATS = {
@@ -1,17 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  class CallLineItem < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_call_line_items"
8
-
9
5
  belongs_to :call,
10
6
  class_name: "LlmCostTracker::Call",
11
7
  foreign_key: :llm_cost_tracker_call_id,
12
8
  inverse_of: :line_items
13
9
 
14
- scope :tokens, -> { where("kind LIKE ?", "%_token") }
10
+ scope :tokens, -> { where(unit: "token") }
15
11
  scope :by_kind, ->(kind) { where(kind: kind.to_s) }
16
12
  scope :by_direction, ->(direction) { where(direction: direction.to_s) }
17
13
  scope :by_modality, ->(modality) { where(modality: modality.to_s) }
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  class CallRollup < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_call_rollups"
8
5
  end
9
6
  end
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  class CallTag < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_call_tags"
8
-
9
5
  belongs_to :call,
10
6
  class_name: "LlmCostTracker::Call",
11
7
  foreign_key: :llm_cost_tracker_call_id,
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  module Ingestion
7
5
  class InboxEntry < ActiveRecord::Base
8
- self.table_name = "llm_cost_tracker_ingestion_inbox_entries"
9
-
10
6
  MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
11
7
  end
12
8
  end
@@ -1,11 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  module Ingestion
7
5
  class Lease < ActiveRecord::Base
8
- self.table_name = "llm_cost_tracker_ingestion_leases"
9
6
  end
10
7
  end
11
8
  end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  class ProviderInvoice < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_provider_invoices"
5
+ before_validation :normalize_currency
6
+
7
+ private
8
+
9
+ def normalize_currency
10
+ self.currency = currency.to_s.upcase if currency.present?
11
+ end
8
12
  end
9
13
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ProviderInvoiceImport < ActiveRecord::Base
5
+ STATE_RUNNING = "running"
6
+ STATE_COMPLETED = "completed"
7
+ STATE_FAILED = "failed"
8
+ STATES = [STATE_RUNNING, STATE_COMPLETED, STATE_FAILED].freeze
9
+
10
+ scope :for_source, ->(source) { where(source: source.to_s) }
11
+ scope :running, -> { where(state: STATE_RUNNING) }
12
+ scope :completed, -> { where(state: STATE_COMPLETED) }
13
+ scope :failed, -> { where(state: STATE_FAILED) }
14
+ scope :latest, -> { order(started_at: :desc, id: :desc) }
15
+
16
+ def self.resume_cursor_for(source)
17
+ for_source(source).latest.limit(1).pick(:cursor)
18
+ end
19
+
20
+ def self.last_completed_window_for(source)
21
+ for_source(source).completed.latest.limit(1).pick(:window_start, :window_end)
22
+ end
23
+ end
24
+ end
@@ -6,7 +6,8 @@ require "llm_cost_tracker/ledger/schema/adapter"
6
6
  module LlmCostTracker
7
7
  module Dashboard
8
8
  class DataQuality
9
- UnknownPricingRow = ::Data.define(:model, :calls, :share_percent)
9
+ UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
10
+ StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
10
11
  Summary = ::Data.define(:total, :unknown_pricing_count, :untagged_calls_count, :missing_latency_count,
11
12
  :streaming_count, :streaming_missing_usage, :missing_provider_response_id_count,
12
13
  :calls_with_pricing, :tagged_calls, :calls_with_latency, :streams_with_usage,
@@ -48,13 +49,14 @@ module LlmCostTracker
48
49
 
49
50
  def unknown_pricing_by_model(scope, total_calls:)
50
51
  scope.unknown_pricing
51
- .group(:model)
52
+ .group(:provider, :model)
52
53
  .order(Arel.sql("COUNT(*) DESC"))
53
- .select("model, COUNT(*) AS calls")
54
+ .select("provider, model, COUNT(*) AS calls")
54
55
  .limit(10)
55
56
  .map do |row|
56
57
  calls = row.calls.to_i
57
- UnknownPricingRow.new(model: row.model, calls: calls, share_percent: percentage(calls, total_calls))
58
+ UnknownPricingRow.new(provider: row.provider, model: row.model, calls: calls,
59
+ share_percent: percentage(calls, total_calls))
58
60
  end
59
61
  end
60
62
 
@@ -125,6 +127,33 @@ module LlmCostTracker
125
127
  index_costs_by_component(rows)
126
128
  end
127
129
 
130
+ def streaming_health_rows(scope, total_streaming:)
131
+ return [] unless total_streaming.positive?
132
+
133
+ unknown_predicate = "usage_source = 'unknown' OR usage_source IS NULL"
134
+ rows = scope.unscope(:select, :order, :group)
135
+ .where(stream: true)
136
+ .group(:provider)
137
+ .order(Arel.sql("COUNT(*) DESC"), :provider)
138
+ .pluck(
139
+ :provider,
140
+ Arel.sql("COUNT(*)"),
141
+ Arel.sql("SUM(CASE WHEN #{unknown_predicate} THEN 1 ELSE 0 END)")
142
+ )
143
+
144
+ rows.map do |provider, streams, unknown|
145
+ streams_count = streams.to_i
146
+ unknown_count = unknown.to_i
147
+ StreamingHealthRow.new(
148
+ provider: provider,
149
+ streams: streams_count,
150
+ with_usage: streams_count - unknown_count,
151
+ unknown: unknown_count,
152
+ unknown_share: percentage(unknown_count, streams_count)
153
+ )
154
+ end
155
+ end
156
+
128
157
  def hidden_output_summary(stats)
129
158
  output_tokens = stats.output_tokens.to_i
130
159
  return unless output_tokens.positive?
@@ -35,11 +35,13 @@ module LlmCostTracker
35
35
  to_date = Dashboard::DateRange.parse(params, :to)
36
36
  Dashboard::DateRange.validate!(from: from_date, to: to_date)
37
37
 
38
- from = from_date&.beginning_of_day
39
- to = to_date&.end_of_day
40
- relation = relation.where(tracked_at: from..) if from
41
- relation = relation.where(tracked_at: ..to) if to
38
+ default_range = Dashboard::DateRange.call(params: params)
39
+ from_date ||= default_range.from
40
+ to_date ||= default_range.to
41
+
42
42
  relation
43
+ .where(tracked_at: from_date.beginning_of_day..)
44
+ .where(tracked_at: ..to_date.end_of_day)
43
45
  end
44
46
 
45
47
  def apply_exact_filter(relation, key)
@@ -1,3 +1,4 @@
1
+ <% body = capture { yield } %>
1
2
  <!DOCTYPE html>
2
3
  <html lang="en">
3
4
  <head>
@@ -5,6 +6,7 @@
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
7
  <title>LLM Cost Tracker</title>
7
8
  <%= stylesheet_link_tag stylesheet_path %>
9
+ <%= inline_style_block %>
8
10
  </head>
9
11
  <body class="lct-body">
10
12
  <div class="lct-app">
@@ -19,10 +21,13 @@
19
21
  <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
20
22
  <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
21
23
  <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
24
+ <% if LlmCostTracker.reconciliation_enabled? %>
25
+ <%= link_to "Reconciliation", reconciliation_path, class: ("lct-active" if request.path.start_with?(reconciliation_path)) %>
26
+ <% end %>
22
27
  </nav>
23
28
  </header>
24
29
 
25
- <%= yield %>
30
+ <%= body %>
26
31
  </main>
27
32
  </div>
28
33
  </body>
@@ -14,15 +14,19 @@ end %>
14
14
  <% end %>
15
15
 
16
16
  <section class="lct-panel">
17
- <p class="lct-muted"><%= link_to "Back to calls", calls_path %></p>
17
+ <nav class="lct-breadcrumb" aria-label="Breadcrumb">
18
+ <%= link_to "Calls", calls_path, class: "lct-breadcrumb-link" %>
19
+ <span class="lct-breadcrumb-sep" aria-hidden="true">›</span>
20
+ <span class="lct-breadcrumb-current">#<%= @call.id %></span>
21
+ </nav>
18
22
  <div class="lct-call-hero">
19
23
  <div>
20
- <h2 class="lct-section-title lct-call-title">Call #<%= @call.id %></h2>
24
+ <h2 class="lct-section-title lct-call-title">
25
+ <code class="lct-code"><%= @call.model %></code>
26
+ </h2>
21
27
  <p class="lct-call-subtitle">
22
28
  <code class="lct-code"><%= @call.provider %></code>
23
29
  <span>·</span>
24
- <code class="lct-code"><%= @call.model %></code>
25
- <span>·</span>
26
30
  <span><%= format_date(@call.tracked_at) %></span>
27
31
  </p>
28
32
  </div>
@@ -65,45 +69,26 @@ end %>
65
69
 
66
70
  <div class="lct-detail-grid">
67
71
  <dl class="lct-dl">
68
- <dt>Tracked At</dt>
69
- <dd><%= format_date(@call.tracked_at) %></dd>
70
-
71
- <dt>Provider</dt>
72
- <dd><%= @call.provider %></dd>
73
-
74
- <dt>Model</dt>
75
- <dd><%= @call.model %></dd>
76
-
77
- <dt>Pricing Status</dt>
78
- <dd><%= pricing_status(@call) %></dd>
79
-
80
72
  <dt>Cost Status</dt>
81
73
  <dd><%= @call.cost_status.presence || "n/a" %></dd>
82
74
 
83
- <dt>Provider Response ID</dt>
84
- <dd><%= @call.provider_response_id.presence || "n/a" %></dd>
85
-
86
- <dt>Provider Project ID</dt>
87
- <dd><%= @call.provider_project_id.presence || "n/a" %></dd>
88
-
89
- <dt>Provider API Key ID</dt>
90
- <dd><%= @call.provider_api_key_id.presence || "n/a" %></dd>
91
-
92
- <dt>Provider Workspace ID</dt>
93
- <dd><%= @call.provider_workspace_id.presence || "n/a" %></dd>
75
+ <dt>Latency</dt>
76
+ <dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
94
77
 
95
78
  <dt>Batch</dt>
96
79
  <dd><%= @call.batch? ? "yes" : "no" %></dd>
97
80
 
98
- <% if @call.has_attribute?("created_at") %>
99
- <dt>Created At</dt>
100
- <dd><%= format_date(@call.created_at) %></dd>
101
- <% end %>
81
+ <dt>Response ID</dt>
82
+ <dd><%= @call.provider_response_id.presence || "n/a" %></dd>
102
83
 
103
- <% if @call.has_attribute?("updated_at") %>
104
- <dt>Updated At</dt>
105
- <dd><%= format_date(@call.updated_at) %></dd>
106
- <% end %>
84
+ <dt>Project ID</dt>
85
+ <dd><%= @call.provider_project_id.present? ? LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id) : "n/a" %></dd>
86
+
87
+ <dt>API Key ID</dt>
88
+ <dd><%= @call.provider_api_key_id.present? ? LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id) : "n/a" %></dd>
89
+
90
+ <dt>Workspace ID</dt>
91
+ <dd><%= @call.provider_workspace_id.present? ? LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id) : "n/a" %></dd>
107
92
  </dl>
108
93
 
109
94
  <dl class="lct-dl">
@@ -114,7 +99,9 @@ end %>
114
99
 
115
100
  <dt>Total Tokens</dt>
116
101
  <dd><%= number(@call.total_tokens) %></dd>
102
+ </dl>
117
103
 
104
+ <dl class="lct-dl">
118
105
  <% priced_components.each do |component| %>
119
106
  <dt><%= component.fetch(:label).titleize %> Cost</dt>
120
107
  <dd><%= optional_money(line_item_costs_by_component[component.fetch(:price_key)]) %></dd>
@@ -122,9 +109,6 @@ end %>
122
109
 
123
110
  <dt>Total Cost</dt>
124
111
  <dd><%= optional_money(@call.total_cost) %></dd>
125
-
126
- <dt>Latency</dt>
127
- <dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
128
112
  </dl>
129
113
  </div>
130
114
  </section>
@@ -152,7 +136,8 @@ end %>
152
136
  <td><%= line_item.unit %></td>
153
137
  <td class="lct-num"><%= line_item.quantity %></td>
154
138
  <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
155
- <td class="lct-num<%= ' lct-num-muted' if line_item.cost.nil? %>"><%= optional_money(line_item.cost) %></td>
139
+ <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
140
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
156
141
  <td><%= line_item.cost_status %></td>
157
142
  </tr>
158
143
  <% end %>
@@ -177,6 +162,6 @@ end %>
177
162
  <% if @call.has_attribute?("metadata") %>
178
163
  <section class="lct-panel">
179
164
  <h2 class="lct-section-title">Metadata</h2>
180
- <pre class="lct-pre"><%= safe_json(@call.read_attribute("metadata")) %></pre>
165
+ <pre class="lct-pre"><%= safe_json(LlmCostTracker::Masking.mask_hash(masked_metadata_hash(@call.read_attribute("metadata")))) %></pre>
181
166
  </section>
182
167
  <% end %>
@@ -14,7 +14,7 @@
14
14
  <h2 class="lct-state-title">No LLM calls yet</h2>
15
15
  <p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
16
16
  <div class="lct-state-actions">
17
- <%= link_to "View calls", calls_path, class: "lct-button lct-button-secondary" %>
17
+ <%= link_to "Calls", calls_path, class: "lct-button lct-button-secondary" %>
18
18
  </div>
19
19
  </section>
20
20
  <% else %>
@@ -46,7 +46,7 @@
46
46
  <% end %>
47
47
  </p>
48
48
  </div>
49
- <%= link_to "Review calls →",
49
+ <%= link_to "Calls",
50
50
  calls_path(current_query(provider: @spend_anomaly.fetch(:provider), model: @spend_anomaly.fetch(:model), from: @to_date.iso8601, to: @to_date.iso8601, page: nil, per: nil, format: nil)),
51
51
  class: "lct-button lct-button-secondary" %>
52
52
  </aside>
@@ -64,6 +64,11 @@
64
64
 
65
65
  <div class="lct-hero-side">
66
66
  <div class="lct-stat-grid">
67
+ <article class="lct-stat">
68
+ <p class="lct-stat-label">Avg cost / call</p>
69
+ <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
70
+ </article>
71
+
67
72
  <article class="lct-stat">
68
73
  <p class="lct-stat-label">Calls</p>
69
74
  <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
@@ -71,11 +76,6 @@
71
76
  <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
72
77
  </article>
73
78
 
74
- <article class="lct-stat">
75
- <p class="lct-stat-label">Avg cost / call</p>
76
- <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
77
- </article>
78
-
79
79
  <% if @stats.average_latency_ms %>
80
80
  <article class="lct-stat">
81
81
  <p class="lct-stat-label">Avg latency</p>
@@ -103,9 +103,9 @@
103
103
  <span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
104
104
  </div>
105
105
  <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
106
- <div class="lct-budget-fill <%= budget[:fill_modifier] %>" style="width: <%= budget[:progress_percent] %>%"></div>
106
+ <div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
107
107
  <% if budget[:projected_spent].positive? %>
108
- <span class="lct-budget-marker" aria-hidden="true" style="left: calc(<%= budget[:projected_marker_percent] %>% - 1px)"></span>
108
+ <span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
109
109
  <% end %>
110
110
  </div>
111
111
  <p class="lct-budget-projection">
@@ -72,6 +72,56 @@
72
72
  </section>
73
73
 
74
74
  <section class="lct-grid lct-two-col">
75
+ <section class="lct-panel">
76
+ <div class="lct-section-head">
77
+ <div>
78
+ <h2 class="lct-section-title">Next actions</h2>
79
+ <p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
80
+ </div>
81
+ </div>
82
+
83
+ <table class="lct-table lct-table-compact">
84
+ <thead>
85
+ <tr>
86
+ <th>Issue</th>
87
+ <th>Why it matters</th>
88
+ <th>Suggested action</th>
89
+ </tr>
90
+ </thead>
91
+ <tbody>
92
+ <tr>
93
+ <td>Unknown pricing</td>
94
+ <td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
95
+ <td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
96
+ </tr>
97
+ <tr>
98
+ <td>Missing tags</td>
99
+ <td>Attribution by tenant, user, or feature becomes less useful.</td>
100
+ <td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
101
+ </tr>
102
+ <tr>
103
+ <td>Missing latency</td>
104
+ <td>Slow requests become harder to isolate on the calls page.</td>
105
+ <td>Make sure latency capture is enabled on every tracked request.</td>
106
+ </tr>
107
+ <% if @summary.streaming_missing_usage.positive? %>
108
+ <tr>
109
+ <td>Streams without usage</td>
110
+ <td>Token totals undercount when streaming responses drop the final usage event.</td>
111
+ <td>Send OpenAI requests with <code class="lct-code">stream_options: { include_usage: true }</code>, or wrap custom clients with <code class="lct-code">LlmCostTracker.track_stream</code>.</td>
112
+ </tr>
113
+ <% end %>
114
+ <% if @summary.missing_provider_response_id_count.positive? %>
115
+ <tr>
116
+ <td>Missing provider response IDs</td>
117
+ <td>Proof of provider-issued responses is weaker when calls cannot be tied back to provider objects.</td>
118
+ <td>Upgrade to the latest parser coverage and pass <code class="lct-code">provider_response_id:</code> for custom clients when the provider exposes one.</td>
119
+ </tr>
120
+ <% end %>
121
+ </tbody>
122
+ </table>
123
+ </section>
124
+
75
125
  <section class="lct-panel">
76
126
  <div class="lct-section-head">
77
127
  <div>
@@ -129,56 +179,6 @@
129
179
  </tbody>
130
180
  </table>
131
181
  </section>
132
-
133
- <section class="lct-panel">
134
- <div class="lct-section-head">
135
- <div>
136
- <h2 class="lct-section-title">Next actions</h2>
137
- <p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
138
- </div>
139
- </div>
140
-
141
- <table class="lct-table lct-table-compact">
142
- <thead>
143
- <tr>
144
- <th>Issue</th>
145
- <th>Why it matters</th>
146
- <th>Suggested action</th>
147
- </tr>
148
- </thead>
149
- <tbody>
150
- <tr>
151
- <td>Unknown pricing</td>
152
- <td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
153
- <td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
154
- </tr>
155
- <tr>
156
- <td>Missing tags</td>
157
- <td>Attribution by tenant, user, or feature becomes less useful.</td>
158
- <td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
159
- </tr>
160
- <tr>
161
- <td>Missing latency</td>
162
- <td>Slow requests become harder to isolate on the calls page.</td>
163
- <td>Make sure latency capture is enabled on every tracked request.</td>
164
- </tr>
165
- <% if @summary.streaming_missing_usage.positive? %>
166
- <tr>
167
- <td>Streams without usage</td>
168
- <td>Token totals undercount when streaming responses drop the final usage event.</td>
169
- <td>Send OpenAI requests with <code class="lct-code">stream_options: { include_usage: true }</code>, or wrap custom clients with <code class="lct-code">LlmCostTracker.track_stream</code>.</td>
170
- </tr>
171
- <% end %>
172
- <% if @summary.missing_provider_response_id_count.positive? %>
173
- <tr>
174
- <td>Missing provider response IDs</td>
175
- <td>Proof of provider-issued responses is weaker when calls cannot be tied back to provider objects.</td>
176
- <td>Upgrade to the latest parser coverage and pass <code class="lct-code">provider_response_id:</code> for custom clients when the provider exposes one.</td>
177
- </tr>
178
- <% end %>
179
- </tbody>
180
- </table>
181
- </section>
182
182
  </section>
183
183
 
184
184
  <section class="lct-panel">
@@ -243,13 +243,50 @@
243
243
  </thead>
244
244
  <tbody>
245
245
  <% @service_charge_rows.each do |row| %>
246
+ <% unknown_cost = row.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
246
247
  <tr>
247
248
  <td><code class="lct-code"><%= row.provider %></code></td>
248
249
  <td><code class="lct-code"><%= row.component %></code></td>
249
250
  <td><%= row.cost_status %></td>
250
251
  <td class="lct-num"><%= number(row.charges_count) %></td>
251
252
  <td class="lct-num"><%= number(row.quantity) %></td>
252
- <td class="lct-num"><%= optional_money(row.total_cost) %></td>
253
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(row.total_cost) %></td>
254
+ </tr>
255
+ <% end %>
256
+ </tbody>
257
+ </table>
258
+ </div>
259
+ </section>
260
+ <% end %>
261
+
262
+ <% if @streaming_health_rows.any? %>
263
+ <section class="lct-panel">
264
+ <div class="lct-section-head">
265
+ <div>
266
+ <h2 class="lct-section-title">Streaming health by provider</h2>
267
+ <p class="lct-section-copy">Streams without a final usage chunk land as <code class="lct-code">usage_source: unknown</code> and undercount tokens. A high unknown share for an OpenAI-compatible provider usually means <code class="lct-code">stream_options: { include_usage: true }</code> is not being injected for that host.</p>
268
+ </div>
269
+ </div>
270
+
271
+ <div class="lct-table-wrap">
272
+ <table class="lct-table lct-table-compact">
273
+ <thead>
274
+ <tr>
275
+ <th>Provider</th>
276
+ <th class="lct-num">Streams</th>
277
+ <th class="lct-num">With usage</th>
278
+ <th class="lct-num">Unknown</th>
279
+ <th class="lct-num">Unknown share</th>
280
+ </tr>
281
+ </thead>
282
+ <tbody>
283
+ <% @streaming_health_rows.each do |row| %>
284
+ <tr>
285
+ <td><code class="lct-code"><%= row.provider %></code></td>
286
+ <td class="lct-num"><%= number(row.streams) %></td>
287
+ <td class="lct-num"><%= number(row.with_usage) %></td>
288
+ <td class="lct-num"><%= number(row.unknown) %></td>
289
+ <td class="lct-num"><%= percent(row.unknown_share) %></td>
253
290
  </tr>
254
291
  <% end %>
255
292
  </tbody>
@@ -265,13 +302,14 @@
265
302
  <h2 class="lct-section-title">Unknown pricing by model</h2>
266
303
  <p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown.</p>
267
304
  </div>
268
- <%= link_to "Review calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
305
+ <%= link_to "Calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
269
306
  </div>
270
307
 
271
308
  <div class="lct-table-wrap">
272
309
  <table class="lct-table lct-table-compact">
273
310
  <thead>
274
311
  <tr>
312
+ <th>Provider</th>
275
313
  <th>Model</th>
276
314
  <th class="lct-num">Calls without cost</th>
277
315
  <th class="lct-num">Share of total</th>
@@ -280,6 +318,7 @@
280
318
  <tbody>
281
319
  <% @unknown_pricing_by_model.each do |row| %>
282
320
  <tr>
321
+ <td><code class="lct-code"><%= row.provider %></code></td>
283
322
  <td><code class="lct-code"><%= row.model %></code></td>
284
323
  <td class="lct-num"><%= number(row.calls) %></td>
285
324
  <td class="lct-num"><%= percent(row.share_percent) %></td>