llm_cost_tracker 0.7.3 → 0.8.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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +66 -1
- data/README.md +58 -225
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +96 -13
- data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -15,7 +15,8 @@ module LlmCostTracker
|
|
|
15
15
|
include TokenUsageHelper
|
|
16
16
|
|
|
17
17
|
def coverage_percent(numerator, denominator)
|
|
18
|
-
|
|
18
|
+
denominator = denominator.to_f
|
|
19
|
+
return 0.0 unless denominator.positive?
|
|
19
20
|
|
|
20
21
|
(numerator.to_f / denominator) * 100.0
|
|
21
22
|
end
|
|
@@ -39,16 +40,19 @@ module LlmCostTracker
|
|
|
39
40
|
number_with_delimiter(value.to_i)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
def format_tokens(value)
|
|
43
|
-
number(value)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
43
|
def format_date(value)
|
|
47
44
|
value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
|
|
48
45
|
end
|
|
49
46
|
|
|
50
47
|
def pricing_status(call)
|
|
51
|
-
|
|
48
|
+
return "Unknown pricing" if call.total_cost.nil?
|
|
49
|
+
return "Estimated" unless call.has_attribute?(:cost_status)
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
|
|
53
|
+
LlmCostTracker::Billing::CostStatus::FREE => "Free",
|
|
54
|
+
LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial pricing"
|
|
55
|
+
}.fetch(call.cost_status, "Unknown pricing")
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def percent(value)
|
|
@@ -100,15 +104,6 @@ module LlmCostTracker
|
|
|
100
104
|
value.to_s
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
def tags_summary(tags, limit: 3)
|
|
104
|
-
tags = normalized_tags(tags)
|
|
105
|
-
return "(untagged)" if tags.empty?
|
|
106
|
-
|
|
107
|
-
summary = tags.first(limit).map { |key, value| "#{key}=#{tag_value_summary(value)}" }
|
|
108
|
-
summary << "+#{tags.size - limit}" if tags.size > limit
|
|
109
|
-
summary.join(", ")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
107
|
def tag_chip_entries(tags, limit: 3)
|
|
113
108
|
normalized = normalized_tags(tags)
|
|
114
109
|
return [] if normalized.empty?
|
|
@@ -124,14 +119,6 @@ module LlmCostTracker
|
|
|
124
119
|
truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
|
|
125
120
|
end
|
|
126
121
|
|
|
127
|
-
def budget_fill_modifier(percent)
|
|
128
|
-
percent = percent.to_f
|
|
129
|
-
return "lct-budget-fill--over" if percent >= 100.0
|
|
130
|
-
return "lct-budget-fill--warn" if percent >= 80.0
|
|
131
|
-
|
|
132
|
-
""
|
|
133
|
-
end
|
|
134
|
-
|
|
135
122
|
def current_query(overrides = {})
|
|
136
123
|
request.query_parameters.symbolize_keys.merge(overrides)
|
|
137
124
|
end
|
|
@@ -164,7 +151,7 @@ module LlmCostTracker
|
|
|
164
151
|
def truncate_text(string, limit)
|
|
165
152
|
return string if string.bytesize <= limit
|
|
166
153
|
|
|
167
|
-
"#{string.byteslice(0, limit).
|
|
154
|
+
"#{string.byteslice(0, limit).encode('UTF-8', invalid: :replace, undef: :replace)}..."
|
|
168
155
|
end
|
|
169
156
|
end
|
|
170
157
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module DashboardFilterHelper
|
|
5
|
-
FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag
|
|
5
|
+
FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag].freeze
|
|
6
6
|
|
|
7
7
|
STREAM_FILTER_OPTIONS = [
|
|
8
8
|
["Streaming only", "yes"],
|
|
@@ -14,33 +14,15 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def active_tag_filters
|
|
17
|
-
tag_params = LlmCostTracker::Dashboard::Params.
|
|
17
|
+
tag_params = LlmCostTracker::Dashboard::Params.tag_query(params[:tag])
|
|
18
18
|
|
|
19
19
|
tag_params.filter_map do |key, value|
|
|
20
|
-
next if key.blank? || value.blank?
|
|
21
|
-
|
|
22
20
|
{
|
|
23
21
|
label: "Tag",
|
|
24
22
|
value: "#{key}=#{value}",
|
|
25
|
-
path: dashboard_filter_path(current_query(tag: tag_params.except(key
|
|
23
|
+
path: dashboard_filter_path(current_query(tag: tag_params.except(key).presence, page: nil))
|
|
26
24
|
}
|
|
27
25
|
end
|
|
28
26
|
end
|
|
29
|
-
|
|
30
|
-
def dashboard_date_range_label(from, to)
|
|
31
|
-
from_label = short_date_label(from) || "Any time"
|
|
32
|
-
to_label = short_date_label(to) || "Now"
|
|
33
|
-
"#{from_label} - #{to_label}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def short_date_label(value)
|
|
39
|
-
return nil if value.blank?
|
|
40
|
-
|
|
41
|
-
Date.iso8601(value.to_s).strftime("%b %-d, %Y")
|
|
42
|
-
rescue ArgumentError
|
|
43
|
-
value.to_s
|
|
44
|
-
end
|
|
45
27
|
end
|
|
46
28
|
end
|
|
@@ -15,14 +15,14 @@ module LlmCostTracker
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def filter_options_for(column, filter_params:)
|
|
18
|
-
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
|
|
19
|
-
scope_params = source.
|
|
20
|
-
column
|
|
18
|
+
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
|
|
19
|
+
scope_params = source.merge(
|
|
20
|
+
column => nil, format: nil, page: nil, per: nil, sort: nil
|
|
21
21
|
)
|
|
22
22
|
values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
|
|
23
23
|
.where.not(column => [nil, ""])
|
|
24
24
|
.distinct.order(column).limit(MAX_FILTER_OPTIONS).pluck(column)
|
|
25
|
-
current = source[column
|
|
25
|
+
current = source[column].presence
|
|
26
26
|
values.unshift(current) if current && !values.include?(current)
|
|
27
27
|
values
|
|
28
28
|
end
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
def calls_query_for_tag(key:, value:)
|
|
13
13
|
query = current_query(page: nil, per: nil, format: nil)
|
|
14
|
-
tags = LlmCostTracker::Dashboard::Params.
|
|
14
|
+
tags = LlmCostTracker::Dashboard::Params.tag_query(query[:tag])
|
|
15
15
|
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
16
|
query
|
|
17
17
|
end
|
|
@@ -6,22 +6,26 @@ module LlmCostTracker
|
|
|
6
6
|
input_tokens: "Input",
|
|
7
7
|
cache_read_input_tokens: "Cache read",
|
|
8
8
|
cache_write_input_tokens: "Cache write",
|
|
9
|
-
|
|
9
|
+
cache_write_extended_input_tokens: "Extended cache write",
|
|
10
|
+
audio_input_tokens: "Audio input",
|
|
10
11
|
output_tokens: "Output",
|
|
12
|
+
audio_output_tokens: "Audio output",
|
|
11
13
|
hidden_output_tokens: "Hidden output"
|
|
12
14
|
}.freeze
|
|
13
15
|
QUALITY_LABELS = COMPONENT_LABELS.merge(
|
|
14
16
|
input_tokens: "Regular input",
|
|
15
17
|
cache_read_input_tokens: "Cache read input",
|
|
16
18
|
cache_write_input_tokens: "Cache write input",
|
|
17
|
-
|
|
19
|
+
cache_write_extended_input_tokens: "Extended cache write input"
|
|
18
20
|
).freeze
|
|
19
21
|
STACK_CLASSES = {
|
|
20
22
|
input_tokens: "lct-stack-fill-input",
|
|
21
23
|
cache_read_input_tokens: "lct-stack-fill-cache-read",
|
|
22
24
|
cache_write_input_tokens: "lct-stack-fill-cache-write",
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
cache_write_extended_input_tokens: "lct-stack-fill-cache-write-extended",
|
|
26
|
+
audio_input_tokens: "lct-stack-fill-audio-input",
|
|
27
|
+
output_tokens: "lct-stack-fill-output",
|
|
28
|
+
audio_output_tokens: "lct-stack-fill-audio-output"
|
|
25
29
|
}.freeze
|
|
26
30
|
|
|
27
31
|
def token_usage_stack_components
|
|
@@ -30,18 +34,26 @@ module LlmCostTracker
|
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|
|
33
|
-
def
|
|
34
|
-
|
|
37
|
+
def call_line_item_costs_by_component(call)
|
|
38
|
+
call.line_items.each_with_object({}) do |line_item, accumulator|
|
|
39
|
+
component = LlmCostTracker::Billing::Components::TOKEN_PRICED.find do |item|
|
|
40
|
+
item.kind.to_s == line_item.kind.to_s &&
|
|
41
|
+
item.direction.to_s == line_item.direction.to_s &&
|
|
42
|
+
item.cache_state.to_s == line_item.cache_state.to_s
|
|
43
|
+
end
|
|
44
|
+
accumulator[component.key] = line_item.cost if component && line_item.cost
|
|
45
|
+
end
|
|
35
46
|
end
|
|
36
47
|
|
|
37
48
|
private
|
|
38
49
|
|
|
39
50
|
def token_usage_display_components(labels:)
|
|
40
|
-
LlmCostTracker::
|
|
51
|
+
LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
|
|
41
52
|
token_key = component.token_key
|
|
42
53
|
{
|
|
43
54
|
token_key: token_key,
|
|
44
55
|
cost_key: component.cost_key,
|
|
56
|
+
price_key: component.key,
|
|
45
57
|
label: labels.fetch(token_key),
|
|
46
58
|
css_class: STACK_CLASSES[token_key]
|
|
47
59
|
}
|
|
@@ -49,6 +61,7 @@ module LlmCostTracker
|
|
|
49
61
|
{
|
|
50
62
|
token_key: :hidden_output_tokens,
|
|
51
63
|
cost_key: nil,
|
|
64
|
+
price_key: nil,
|
|
52
65
|
label: labels.fetch(:hidden_output_tokens),
|
|
53
66
|
css_class: nil
|
|
54
67
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require "llm_cost_tracker/billing/cost_status"
|
|
7
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
8
|
+
require "llm_cost_tracker/ledger/tags/sql"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
class Call < ActiveRecord::Base
|
|
12
|
+
self.table_name = "llm_cost_tracker_calls"
|
|
13
|
+
|
|
14
|
+
before_validation :assign_event_id
|
|
15
|
+
|
|
16
|
+
PERIOD_FORMATS = {
|
|
17
|
+
day: {
|
|
18
|
+
postgres: "YYYY-MM-DD",
|
|
19
|
+
mysql: "%Y-%m-%d"
|
|
20
|
+
},
|
|
21
|
+
month: {
|
|
22
|
+
postgres: "YYYY-MM",
|
|
23
|
+
mysql: "%Y-%m"
|
|
24
|
+
}
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
private_constant :PERIOD_FORMATS
|
|
28
|
+
|
|
29
|
+
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
30
|
+
scope :without_cost, -> { where(total_cost: nil) }
|
|
31
|
+
scope :unknown_pricing, lambda {
|
|
32
|
+
where(total_cost: nil).or(
|
|
33
|
+
where(cost_status: [Billing::CostStatus::UNKNOWN, Billing::CostStatus::PARTIAL])
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
scope :with_latency, -> { where.not(latency_ms: nil) }
|
|
37
|
+
scope :streaming, -> { where(stream: true) }
|
|
38
|
+
scope :non_streaming, -> { where(stream: [false, nil]) }
|
|
39
|
+
scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
|
|
40
|
+
scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
|
|
41
|
+
scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
|
|
42
|
+
scope :streaming_missing_usage, lambda {
|
|
43
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
has_many :line_items,
|
|
47
|
+
class_name: "LlmCostTracker::CallLineItem",
|
|
48
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
49
|
+
inverse_of: :call,
|
|
50
|
+
dependent: :delete_all
|
|
51
|
+
|
|
52
|
+
has_many :tag_records,
|
|
53
|
+
class_name: "LlmCostTracker::CallTag",
|
|
54
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
55
|
+
inverse_of: :call,
|
|
56
|
+
dependent: :delete_all
|
|
57
|
+
|
|
58
|
+
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
59
|
+
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
60
|
+
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
61
|
+
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
def by_tag(key, value) = by_tags(key => value)
|
|
65
|
+
|
|
66
|
+
def by_tags(tags) = Ledger::Tags::Query.apply(tags)
|
|
67
|
+
|
|
68
|
+
def total_cost = sum(:total_cost).to_f
|
|
69
|
+
|
|
70
|
+
def total_tokens = sum(:total_tokens).to_i
|
|
71
|
+
|
|
72
|
+
def cost_by_model(limit: nil) = cost_by_column(:model, limit: limit)
|
|
73
|
+
|
|
74
|
+
def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
|
|
75
|
+
|
|
76
|
+
def group_by_tag(key)
|
|
77
|
+
Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cost_by_tag(key, limit: nil)
|
|
81
|
+
label = Ledger::Tags::Sql.label_sql(connection)
|
|
82
|
+
raw_value = Ledger::Tags::Sql.raw_value_sql(connection)
|
|
83
|
+
relation = Ledger::Tags::Sql.join_relation(self, key)
|
|
84
|
+
.select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
85
|
+
.group(Arel.sql(label))
|
|
86
|
+
.order(
|
|
87
|
+
Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
|
|
88
|
+
Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
|
|
89
|
+
Arel.sql("#{label} DESC")
|
|
90
|
+
)
|
|
91
|
+
relation = relation.limit(limit) if limit
|
|
92
|
+
relation
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def average_latency_ms = average(:latency_ms)&.to_f
|
|
96
|
+
|
|
97
|
+
def latency_by_model = group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
98
|
+
|
|
99
|
+
def latency_by_provider = group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
100
|
+
|
|
101
|
+
def group_by_period(period, column: :tracked_at)
|
|
102
|
+
group(Arel.sql(period_group_expression(period, column: column)))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def daily_costs(days: 30)
|
|
106
|
+
where(tracked_at: days.days.ago..)
|
|
107
|
+
.group_by_period(:day)
|
|
108
|
+
.sum(:total_cost)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def cost_by_column(column, limit:)
|
|
114
|
+
quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
|
|
115
|
+
relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
116
|
+
.group(column)
|
|
117
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
118
|
+
relation = relation.limit(limit) if limit
|
|
119
|
+
relation
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def period_group_expression(period, column:)
|
|
123
|
+
period = validated_period(period)
|
|
124
|
+
column = period_column_expression(column)
|
|
125
|
+
formats = PERIOD_FORMATS.fetch(period)
|
|
126
|
+
|
|
127
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
128
|
+
postgres_period_expression(period, column, formats)
|
|
129
|
+
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
130
|
+
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
131
|
+
else
|
|
132
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def postgres_period_expression(period, column, formats)
|
|
137
|
+
"TO_CHAR(" \
|
|
138
|
+
"DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
|
|
139
|
+
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
140
|
+
")"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validated_period(period)
|
|
144
|
+
return period if PERIOD_FORMATS.key?(period)
|
|
145
|
+
|
|
146
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def period_column_expression(column)
|
|
150
|
+
column = column.to_s
|
|
151
|
+
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parsed_tags
|
|
158
|
+
tag_records.to_h do |record|
|
|
159
|
+
[record.key, record.value]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def assign_event_id
|
|
166
|
+
self.event_id ||= SecureRandom.uuid
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class CallLineItem < ActiveRecord::Base
|
|
7
|
+
self.table_name = "llm_cost_tracker_call_line_items"
|
|
8
|
+
|
|
9
|
+
belongs_to :call,
|
|
10
|
+
class_name: "LlmCostTracker::Call",
|
|
11
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
12
|
+
inverse_of: :line_items
|
|
13
|
+
|
|
14
|
+
scope :tokens, -> { where("kind LIKE ?", "%_token") }
|
|
15
|
+
scope :by_kind, ->(kind) { where(kind: kind.to_s) }
|
|
16
|
+
scope :by_direction, ->(direction) { where(direction: direction.to_s) }
|
|
17
|
+
scope :by_modality, ->(modality) { where(modality: modality.to_s) }
|
|
18
|
+
scope :cached, -> { where.not(cache_state: ["none", nil]) }
|
|
19
|
+
scope :priced, -> { where(cost_status: %w[complete free]) }
|
|
20
|
+
scope :unpriced, -> { where(cost_status: "unknown") }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class CallTag < ActiveRecord::Base
|
|
7
|
+
self.table_name = "llm_cost_tracker_call_tags"
|
|
8
|
+
|
|
9
|
+
belongs_to :call,
|
|
10
|
+
class_name: "LlmCostTracker::Call",
|
|
11
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
12
|
+
inverse_of: :tag_records
|
|
13
|
+
|
|
14
|
+
scope :with_key, ->(key) { where(key: key.to_s) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ingestion
|
|
7
|
+
class InboxEntry < ActiveRecord::Base
|
|
8
|
+
self.table_name = "llm_cost_tracker_ingestion_inbox_entries"
|
|
9
|
+
|
|
10
|
+
MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|