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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +21 -16
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +39 -69
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
class AddUsageBreakdownToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
-
def up
|
|
3
|
-
unless column_exists?(:llm_api_calls, :cache_read_input_tokens)
|
|
4
|
-
add_column :llm_api_calls, :cache_read_input_tokens, :integer, null: false, default: 0
|
|
5
|
-
end
|
|
6
|
-
unless column_exists?(:llm_api_calls, :cache_write_input_tokens)
|
|
7
|
-
add_column :llm_api_calls, :cache_write_input_tokens, :integer, null: false, default: 0
|
|
8
|
-
end
|
|
9
|
-
unless column_exists?(:llm_api_calls, :hidden_output_tokens)
|
|
10
|
-
add_column :llm_api_calls, :hidden_output_tokens, :integer, null: false, default: 0
|
|
11
|
-
end
|
|
12
|
-
unless column_exists?(:llm_api_calls, :cache_read_input_cost)
|
|
13
|
-
add_column :llm_api_calls, :cache_read_input_cost, :decimal, precision: 20, scale: 8
|
|
14
|
-
end
|
|
15
|
-
unless column_exists?(:llm_api_calls, :cache_write_input_cost)
|
|
16
|
-
add_column :llm_api_calls, :cache_write_input_cost, :decimal, precision: 20, scale: 8
|
|
17
|
-
end
|
|
18
|
-
add_column :llm_api_calls, :pricing_mode, :string unless column_exists?(:llm_api_calls, :pricing_mode)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def down
|
|
22
|
-
remove_column :llm_api_calls, :pricing_mode if column_exists?(:llm_api_calls, :pricing_mode)
|
|
23
|
-
remove_column :llm_api_calls, :cache_write_input_cost if column_exists?(:llm_api_calls, :cache_write_input_cost)
|
|
24
|
-
remove_column :llm_api_calls, :cache_read_input_cost if column_exists?(:llm_api_calls, :cache_read_input_cost)
|
|
25
|
-
remove_column :llm_api_calls, :hidden_output_tokens if column_exists?(:llm_api_calls, :hidden_output_tokens)
|
|
26
|
-
remove_column :llm_api_calls, :cache_write_input_tokens if column_exists?(:llm_api_calls, :cache_write_input_tokens)
|
|
27
|
-
remove_column :llm_api_calls, :cache_read_input_tokens if column_exists?(:llm_api_calls, :cache_read_input_tokens)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
module Integrations
|
|
5
|
-
module ObjectReader
|
|
6
|
-
module_function
|
|
7
|
-
|
|
8
|
-
def first(object, *keys)
|
|
9
|
-
keys.each do |key|
|
|
10
|
-
value = read(object, key)
|
|
11
|
-
return value unless value.nil?
|
|
12
|
-
end
|
|
13
|
-
nil
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def nested(object, *path)
|
|
17
|
-
path.reduce(object) do |current, key|
|
|
18
|
-
return nil if current.nil?
|
|
19
|
-
|
|
20
|
-
read(current, key)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def read(object, key)
|
|
25
|
-
return nil if object.nil?
|
|
26
|
-
|
|
27
|
-
read_hash(object, key) || read_method(object, key) || read_index(object, key)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def integer(value)
|
|
31
|
-
value.nil? ? 0 : value.to_i
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def read_hash(object, key)
|
|
35
|
-
return unless object.respond_to?(:key?)
|
|
36
|
-
|
|
37
|
-
return object[key] if object.key?(key)
|
|
38
|
-
|
|
39
|
-
string_key = key.to_s
|
|
40
|
-
object[string_key] if object.key?(string_key)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def read_method(object, key)
|
|
44
|
-
object.public_send(key) if object.respond_to?(key)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def read_index(object, key)
|
|
48
|
-
return unless object.respond_to?(:[])
|
|
49
|
-
|
|
50
|
-
object[key]
|
|
51
|
-
rescue IndexError, NameError, TypeError
|
|
52
|
-
nil
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "monitor"
|
|
4
|
-
|
|
5
|
-
require_relative "../errors"
|
|
6
|
-
require_relative "openai"
|
|
7
|
-
require_relative "anthropic"
|
|
8
|
-
require_relative "ruby_llm"
|
|
9
|
-
|
|
10
|
-
module LlmCostTracker
|
|
11
|
-
module Integrations
|
|
12
|
-
module Registry
|
|
13
|
-
DEFAULT_INTEGRATIONS = {
|
|
14
|
-
openai: Openai,
|
|
15
|
-
anthropic: Anthropic,
|
|
16
|
-
ruby_llm: RubyLlm
|
|
17
|
-
}.freeze
|
|
18
|
-
MUTEX = Monitor.new
|
|
19
|
-
|
|
20
|
-
module_function
|
|
21
|
-
|
|
22
|
-
def register(name, integration)
|
|
23
|
-
key = name.to_sym
|
|
24
|
-
validate_integration!(integration)
|
|
25
|
-
MUTEX.synchronize { @integrations = integrations.merge(key => integration).freeze }
|
|
26
|
-
integration
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
30
|
-
normalize(names).each { |name| fetch(name).install }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
34
|
-
return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
|
|
35
|
-
|
|
36
|
-
normalize(names).map { |name| fetch(name).status }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def normalize(names)
|
|
40
|
-
Array(names).flatten.map(&:to_sym).uniq
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def fetch(name)
|
|
44
|
-
integrations.fetch(name.to_sym) do
|
|
45
|
-
message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
46
|
-
raise LlmCostTracker::Error, message
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def names
|
|
51
|
-
integrations.keys
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def reset!
|
|
55
|
-
MUTEX.synchronize { @integrations = DEFAULT_INTEGRATIONS.dup.freeze }
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def integrations
|
|
59
|
-
@integrations || MUTEX.synchronize { @integrations ||= DEFAULT_INTEGRATIONS.dup.freeze }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def validate_integration!(integration)
|
|
63
|
-
return if integration.respond_to?(:install) && integration.respond_to?(:status)
|
|
64
|
-
|
|
65
|
-
raise ArgumentError, "integration must respond to install and status"
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def self.register(name, integration) = Registry.register(name, integration)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_record"
|
|
4
|
-
|
|
5
|
-
require_relative "llm_api_call_metrics"
|
|
6
|
-
require_relative "period_grouping"
|
|
7
|
-
require_relative "tag_accessors"
|
|
8
|
-
require_relative "tag_query"
|
|
9
|
-
require_relative "tags_column"
|
|
10
|
-
|
|
11
|
-
module LlmCostTracker
|
|
12
|
-
class LlmApiCall < ActiveRecord::Base
|
|
13
|
-
extend PeriodGrouping
|
|
14
|
-
extend TagsColumn
|
|
15
|
-
extend LlmApiCallMetrics
|
|
16
|
-
include TagAccessors
|
|
17
|
-
|
|
18
|
-
self.table_name = "llm_api_calls"
|
|
19
|
-
|
|
20
|
-
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
21
|
-
scope :without_cost, -> { where(total_cost: nil) }
|
|
22
|
-
scope :unknown_pricing, -> { without_cost }
|
|
23
|
-
scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
|
|
24
|
-
scope :streaming, -> { stream_column? ? where(stream: true) : none }
|
|
25
|
-
scope :non_streaming, -> { stream_column? ? where(stream: [false, nil]) : all }
|
|
26
|
-
scope :by_usage_source, ->(source) { usage_source_column? ? where(usage_source: source.to_s) : none }
|
|
27
|
-
scope :with_provider_response_id, lambda {
|
|
28
|
-
provider_response_id_column? ? where.not(provider_response_id: [nil, ""]) : none
|
|
29
|
-
}
|
|
30
|
-
scope :missing_provider_response_id, lambda {
|
|
31
|
-
provider_response_id_column? ? where(provider_response_id: [nil, ""]) : none
|
|
32
|
-
}
|
|
33
|
-
scope :streaming_missing_usage, lambda {
|
|
34
|
-
return none unless stream_column? && usage_source_column?
|
|
35
|
-
|
|
36
|
-
where(stream: true).where(usage_source: ["unknown", nil])
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
scope :with_json_tags, lambda {
|
|
40
|
-
if tags_json_column?
|
|
41
|
-
where.not(tags: {})
|
|
42
|
-
else
|
|
43
|
-
where.not(tags: [nil, "", "{}"])
|
|
44
|
-
end
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
48
|
-
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
49
|
-
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
50
|
-
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
51
|
-
|
|
52
|
-
def self.by_tag(key, value)
|
|
53
|
-
by_tags(key => value)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def self.by_tags(tags)
|
|
57
|
-
TagQuery.apply(self, tags)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "tag_sql"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module LlmApiCallMetrics
|
|
7
|
-
def total_cost
|
|
8
|
-
sum(:total_cost).to_f
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def total_tokens
|
|
12
|
-
sum(:total_tokens).to_i
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def cost_by_model
|
|
16
|
-
group(:model).sum(:total_cost)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def cost_by_provider
|
|
20
|
-
group(:provider).sum(:total_cost)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def group_by_tag(key)
|
|
24
|
-
group(Arel.sql(tag_value_expression(key)))
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def cost_by_tag(key, limit: nil)
|
|
28
|
-
relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
29
|
-
relation = relation.limit(limit) if limit
|
|
30
|
-
|
|
31
|
-
costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
|
|
32
|
-
grouped[tag_value_label(tag_value)] += cost.to_f
|
|
33
|
-
end
|
|
34
|
-
costs.sort_by { |_label, cost| -cost }.to_h
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def average_latency_ms
|
|
38
|
-
return nil unless latency_column?
|
|
39
|
-
|
|
40
|
-
average(:latency_ms)&.to_f
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def latency_by_model
|
|
44
|
-
return {} unless latency_column?
|
|
45
|
-
|
|
46
|
-
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def latency_by_provider
|
|
50
|
-
return {} unless latency_column?
|
|
51
|
-
|
|
52
|
-
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def tag_value_label(value)
|
|
56
|
-
TagSql.value_label(value)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def tag_value_expression(key, table_name: quoted_table_name)
|
|
60
|
-
TagSql.value_expression(self, key, table_name: table_name)
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
module ParameterHash
|
|
5
|
-
class << self
|
|
6
|
-
def hash_like?(value)
|
|
7
|
-
value.is_a?(Hash) || action_controller_parameters?(value)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def to_hash(value)
|
|
11
|
-
return {} if value.nil?
|
|
12
|
-
return value.to_unsafe_h if action_controller_parameters?(value)
|
|
13
|
-
return value.to_h if value.is_a?(Hash)
|
|
14
|
-
return {} unless value.respond_to?(:to_h)
|
|
15
|
-
|
|
16
|
-
hash = value.to_h
|
|
17
|
-
hash.is_a?(Hash) ? hash : {}
|
|
18
|
-
rescue ArgumentError, TypeError
|
|
19
|
-
{}
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def with_indifferent_access(value)
|
|
23
|
-
to_hash(value).with_indifferent_access
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
def action_controller_parameters?(value)
|
|
29
|
-
defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
ParsedUsage = Data.define(
|
|
5
|
-
:provider,
|
|
6
|
-
:model,
|
|
7
|
-
:input_tokens,
|
|
8
|
-
:output_tokens,
|
|
9
|
-
:total_tokens,
|
|
10
|
-
:cache_read_input_tokens,
|
|
11
|
-
:cache_write_input_tokens,
|
|
12
|
-
:hidden_output_tokens,
|
|
13
|
-
:stream,
|
|
14
|
-
:usage_source,
|
|
15
|
-
:provider_response_id
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
class ParsedUsage
|
|
19
|
-
UNKNOWN_MODEL = "unknown"
|
|
20
|
-
TRACKING_KEYS = %i[
|
|
21
|
-
provider
|
|
22
|
-
model
|
|
23
|
-
input_tokens
|
|
24
|
-
output_tokens
|
|
25
|
-
total_tokens
|
|
26
|
-
stream
|
|
27
|
-
usage_source
|
|
28
|
-
provider_response_id
|
|
29
|
-
].freeze
|
|
30
|
-
|
|
31
|
-
def self.build(**attributes)
|
|
32
|
-
new(
|
|
33
|
-
provider: attributes.fetch(:provider),
|
|
34
|
-
model: normalize_model(attributes.fetch(:model)),
|
|
35
|
-
input_tokens: attributes.fetch(:input_tokens).to_i,
|
|
36
|
-
output_tokens: attributes.fetch(:output_tokens).to_i,
|
|
37
|
-
total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
|
|
38
|
-
cache_read_input_tokens: attributes[:cache_read_input_tokens],
|
|
39
|
-
cache_write_input_tokens: attributes[:cache_write_input_tokens],
|
|
40
|
-
hidden_output_tokens: attributes[:hidden_output_tokens],
|
|
41
|
-
stream: attributes[:stream] || false,
|
|
42
|
-
usage_source: attributes[:usage_source],
|
|
43
|
-
provider_response_id: attributes[:provider_response_id]
|
|
44
|
-
)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def metadata
|
|
48
|
-
to_h.except(*TRACKING_KEYS)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def to_h
|
|
52
|
-
super.compact
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def self.usage_breakdown(attributes)
|
|
56
|
-
UsageBreakdown.build(
|
|
57
|
-
input_tokens: attributes.fetch(:input_tokens),
|
|
58
|
-
output_tokens: attributes.fetch(:output_tokens),
|
|
59
|
-
cache_read_input_tokens: attributes[:cache_read_input_tokens],
|
|
60
|
-
cache_write_input_tokens: attributes[:cache_write_input_tokens],
|
|
61
|
-
hidden_output_tokens: attributes[:hidden_output_tokens]
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
private_class_method :usage_breakdown
|
|
65
|
-
|
|
66
|
-
def self.normalize_model(value)
|
|
67
|
-
model = value.to_s.strip
|
|
68
|
-
model.empty? ? UNKNOWN_MODEL : model
|
|
69
|
-
end
|
|
70
|
-
private_class_method :normalize_model
|
|
71
|
-
end
|
|
72
|
-
end
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "monitor"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module Parsers
|
|
7
|
-
class Registry
|
|
8
|
-
MUTEX = Monitor.new
|
|
9
|
-
|
|
10
|
-
class << self
|
|
11
|
-
def parsers
|
|
12
|
-
@parsers || MUTEX.synchronize { @parsers ||= default_parsers.freeze }
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def register(parser)
|
|
16
|
-
parser = coerce_parser(parser)
|
|
17
|
-
|
|
18
|
-
MUTEX.synchronize do
|
|
19
|
-
current = @parsers || default_parsers.freeze
|
|
20
|
-
@parsers = ([parser] + current).freeze
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
parser
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def find_for(url)
|
|
27
|
-
parsers.find { |parser| parser.match?(url) }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def find_for_provider(provider)
|
|
31
|
-
provider_name = provider.to_s.downcase
|
|
32
|
-
parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def reset!
|
|
36
|
-
MUTEX.synchronize { @parsers = nil }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def coerce_parser(parser)
|
|
42
|
-
return parser.new if parser.is_a?(Class) && parser <= Base
|
|
43
|
-
return parser if parser.is_a?(Base)
|
|
44
|
-
|
|
45
|
-
raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def provider_names_for(parser)
|
|
49
|
-
Array(parser.provider_names).map { |name| name.to_s.downcase }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def default_parsers
|
|
53
|
-
[Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "active_record_adapter"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module PeriodGrouping
|
|
7
|
-
PERIOD_FORMATS = {
|
|
8
|
-
day: {
|
|
9
|
-
postgres: "YYYY-MM-DD",
|
|
10
|
-
mysql: "%Y-%m-%d"
|
|
11
|
-
},
|
|
12
|
-
month: {
|
|
13
|
-
postgres: "YYYY-MM",
|
|
14
|
-
mysql: "%Y-%m"
|
|
15
|
-
}
|
|
16
|
-
}.freeze
|
|
17
|
-
|
|
18
|
-
private_constant :PERIOD_FORMATS
|
|
19
|
-
|
|
20
|
-
def group_by_period(period, column: :tracked_at)
|
|
21
|
-
group(Arel.sql(period_group_expression(period, column: column)))
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def daily_costs(days: 30)
|
|
25
|
-
where(tracked_at: days.days.ago..)
|
|
26
|
-
.group_by_period(:day)
|
|
27
|
-
.sum(:total_cost)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def period_group_expression(period, column:)
|
|
33
|
-
period = validated_period(period)
|
|
34
|
-
column = period_column_expression(column)
|
|
35
|
-
formats = PERIOD_FORMATS.fetch(period)
|
|
36
|
-
|
|
37
|
-
if ActiveRecordAdapter.postgresql?(connection)
|
|
38
|
-
postgres_period_expression(period, column, formats)
|
|
39
|
-
elsif ActiveRecordAdapter.mysql?(connection)
|
|
40
|
-
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
41
|
-
else
|
|
42
|
-
ActiveRecordAdapter.ensure_supported!(connection)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def postgres_period_expression(period, column, formats)
|
|
47
|
-
"TO_CHAR(" \
|
|
48
|
-
"DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
|
|
49
|
-
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
50
|
-
")"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def validated_period(period)
|
|
54
|
-
normalized_period = period.respond_to?(:to_sym) ? period.to_sym : nil
|
|
55
|
-
return normalized_period if PERIOD_FORMATS.key?(normalized_period)
|
|
56
|
-
|
|
57
|
-
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def period_column_expression(column)
|
|
61
|
-
column = column.to_s
|
|
62
|
-
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
63
|
-
|
|
64
|
-
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module PriceFreshness
|
|
7
|
-
STALE_AFTER_DAYS = 30
|
|
8
|
-
|
|
9
|
-
class << self
|
|
10
|
-
def call(metadata, today: Date.today)
|
|
11
|
-
updated_at = metadata["updated_at"] || metadata[:updated_at]
|
|
12
|
-
return missing unless updated_at
|
|
13
|
-
|
|
14
|
-
date = Date.iso8601(updated_at.to_s)
|
|
15
|
-
age_days = (today - date).to_i
|
|
16
|
-
return stale(updated_at) if age_days > STALE_AFTER_DAYS
|
|
17
|
-
|
|
18
|
-
[:ok, "updated_at=#{updated_at}"]
|
|
19
|
-
rescue Date::Error
|
|
20
|
-
[:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:refresh"]
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def missing
|
|
26
|
-
[:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def stale(updated_at)
|
|
30
|
-
[
|
|
31
|
-
:warn,
|
|
32
|
-
"updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
|
|
33
|
-
"run bin/rails llm_cost_tracker:prices:refresh"
|
|
34
|
-
]
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|