llm_cost_tracker 0.1.1 → 0.1.3
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 +69 -0
- data/README.md +333 -30
- data/lib/llm_cost_tracker/budget.rb +85 -0
- data/lib/llm_cost_tracker/configuration.rb +82 -3
- data/lib/llm_cost_tracker/cost.rb +15 -0
- data/lib/llm_cost_tracker/errors.rb +37 -0
- data/lib/llm_cost_tracker/event.rb +24 -0
- data/lib/llm_cost_tracker/event_metadata.rb +54 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +9 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +16 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +41 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +29 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +45 -14
- data/lib/llm_cost_tracker/logging.rb +44 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +54 -13
- data/lib/llm_cost_tracker/parsed_usage.rb +45 -0
- data/lib/llm_cost_tracker/parsers/anthropic.rb +6 -4
- data/lib/llm_cost_tracker/parsers/base.rb +2 -0
- data/lib/llm_cost_tracker/parsers/gemini.rb +12 -5
- data/lib/llm_cost_tracker/parsers/openai.rb +11 -22
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +48 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +33 -0
- data/lib/llm_cost_tracker/parsers/registry.rb +16 -7
- data/lib/llm_cost_tracker/price_registry.rb +99 -0
- data/lib/llm_cost_tracker/prices.json +51 -0
- data/lib/llm_cost_tracker/pricing.rb +103 -77
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/report.rb +29 -0
- data/lib/llm_cost_tracker/report_data.rb +84 -0
- data/lib/llm_cost_tracker/report_formatter.rb +59 -0
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +19 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +21 -12
- data/lib/llm_cost_tracker/storage/backends.rb +26 -0
- data/lib/llm_cost_tracker/storage/custom_backend.rb +16 -0
- data/lib/llm_cost_tracker/storage/log_backend.rb +28 -0
- data/lib/llm_cost_tracker/tag_accessors.rb +23 -0
- data/lib/llm_cost_tracker/tag_query.rb +38 -0
- data/lib/llm_cost_tracker/tags_column.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +43 -97
- data/lib/llm_cost_tracker/unknown_pricing.rb +40 -0
- data/lib/llm_cost_tracker/value_object.rb +45 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +49 -6
- data/lib/tasks/llm_cost_tracker.rake +9 -0
- data/llm_cost_tracker.gemspec +4 -3
- metadata +39 -6
|
@@ -1,25 +1,88 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
class Configuration
|
|
7
|
+
# Hostname => provider name for OpenAI-compatible APIs.
|
|
8
|
+
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
9
|
+
"openrouter.ai" => "openrouter",
|
|
10
|
+
"api.deepseek.com" => "deepseek"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
14
|
+
STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
|
|
15
|
+
STORAGE_BACKENDS = %i[log active_record custom].freeze
|
|
16
|
+
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
17
|
+
|
|
5
18
|
attr_accessor :enabled,
|
|
6
|
-
:storage_backend, # :log, :active_record, :custom
|
|
7
19
|
:custom_storage, # callable object for :custom backend
|
|
8
20
|
:default_tags, # Hash of default tags added to every event
|
|
9
21
|
:on_budget_exceeded, # callable, receives event hash
|
|
10
22
|
:monthly_budget, # Float, in USD — nil means no limit
|
|
11
23
|
:log_level, # :debug, :info, :warn
|
|
24
|
+
:prices_file, # JSON/YAML file that overrides built-in prices
|
|
12
25
|
:pricing_overrides # Hash to override built-in pricing
|
|
13
26
|
|
|
27
|
+
attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
|
|
28
|
+
:storage_backend, # :log, :active_record, :custom
|
|
29
|
+
:storage_error_behavior, # :ignore, :warn, :raise
|
|
30
|
+
:unknown_pricing_behavior, # :ignore, :warn, :raise
|
|
31
|
+
:openai_compatible_providers
|
|
32
|
+
|
|
14
33
|
def initialize
|
|
15
|
-
@enabled
|
|
16
|
-
|
|
34
|
+
@enabled = true
|
|
35
|
+
self.storage_backend = :log
|
|
17
36
|
@custom_storage = nil
|
|
18
37
|
@default_tags = {}
|
|
19
38
|
@on_budget_exceeded = nil
|
|
20
39
|
@monthly_budget = nil
|
|
40
|
+
self.budget_exceeded_behavior = :notify
|
|
41
|
+
self.storage_error_behavior = :warn
|
|
42
|
+
self.unknown_pricing_behavior = :warn
|
|
21
43
|
@log_level = :info
|
|
44
|
+
@prices_file = nil
|
|
22
45
|
@pricing_overrides = {}
|
|
46
|
+
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def openai_compatible_providers=(providers)
|
|
50
|
+
@openai_compatible_providers = normalize_openai_compatible_providers(providers)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def storage_backend=(value)
|
|
54
|
+
@storage_backend = normalize_enum(:storage_backend, value, STORAGE_BACKENDS, default: :log)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def budget_exceeded_behavior=(value)
|
|
58
|
+
@budget_exceeded_behavior = normalize_enum(
|
|
59
|
+
:budget_exceeded_behavior,
|
|
60
|
+
value,
|
|
61
|
+
BUDGET_EXCEEDED_BEHAVIORS,
|
|
62
|
+
default: :notify
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def storage_error_behavior=(value)
|
|
67
|
+
@storage_error_behavior = normalize_enum(
|
|
68
|
+
:storage_error_behavior,
|
|
69
|
+
value,
|
|
70
|
+
STORAGE_ERROR_BEHAVIORS,
|
|
71
|
+
default: :warn
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unknown_pricing_behavior=(value)
|
|
76
|
+
@unknown_pricing_behavior = normalize_enum(
|
|
77
|
+
:unknown_pricing_behavior,
|
|
78
|
+
value,
|
|
79
|
+
UNKNOWN_PRICING_BEHAVIORS,
|
|
80
|
+
default: :warn
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalize_openai_compatible_providers!
|
|
85
|
+
self.openai_compatible_providers = openai_compatible_providers
|
|
23
86
|
end
|
|
24
87
|
|
|
25
88
|
def active_record?
|
|
@@ -29,5 +92,21 @@ module LlmCostTracker
|
|
|
29
92
|
def log?
|
|
30
93
|
storage_backend == :log
|
|
31
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def normalize_enum(name, value, allowed, default:)
|
|
99
|
+
value = default if value.nil?
|
|
100
|
+
value = value.to_sym
|
|
101
|
+
return value if allowed.include?(value)
|
|
102
|
+
|
|
103
|
+
raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_openai_compatible_providers(providers)
|
|
107
|
+
(providers || {}).each_with_object({}) do |(host, provider), normalized|
|
|
108
|
+
normalized[host.to_s.downcase] = provider.to_s
|
|
109
|
+
end
|
|
110
|
+
end
|
|
32
111
|
end
|
|
33
112
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "value_object"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
Cost = ValueObject.define(
|
|
7
|
+
:input_cost,
|
|
8
|
+
:cached_input_cost,
|
|
9
|
+
:cache_read_input_cost,
|
|
10
|
+
:cache_creation_input_cost,
|
|
11
|
+
:output_cost,
|
|
12
|
+
:total_cost,
|
|
13
|
+
:currency
|
|
14
|
+
)
|
|
15
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class BudgetExceededError < Error
|
|
7
|
+
attr_reader :monthly_total, :budget, :last_event
|
|
8
|
+
|
|
9
|
+
def initialize(monthly_total:, budget:, last_event: nil)
|
|
10
|
+
@monthly_total = monthly_total
|
|
11
|
+
@budget = budget
|
|
12
|
+
@last_event = last_event
|
|
13
|
+
|
|
14
|
+
super("LLM monthly budget exceeded: $#{format('%.6f', monthly_total)} / $#{format('%.6f', budget)}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class UnknownPricingError < Error
|
|
19
|
+
attr_reader :model
|
|
20
|
+
|
|
21
|
+
def initialize(model:)
|
|
22
|
+
@model = model
|
|
23
|
+
|
|
24
|
+
super("No pricing configured for LLM model: #{model.inspect}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class StorageError < Error
|
|
29
|
+
attr_reader :original_error
|
|
30
|
+
|
|
31
|
+
def initialize(original_error)
|
|
32
|
+
@original_error = original_error
|
|
33
|
+
|
|
34
|
+
super("Failed to store LLM cost event: #{original_error.class}: #{original_error.message}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "value_object"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
Event = ValueObject.define(
|
|
7
|
+
:provider,
|
|
8
|
+
:model,
|
|
9
|
+
:input_tokens,
|
|
10
|
+
:output_tokens,
|
|
11
|
+
:total_tokens,
|
|
12
|
+
:cost,
|
|
13
|
+
:tags,
|
|
14
|
+
:latency_ms,
|
|
15
|
+
:tracked_at
|
|
16
|
+
) do
|
|
17
|
+
def to_h
|
|
18
|
+
super.merge(
|
|
19
|
+
cost: cost&.to_h,
|
|
20
|
+
tags: tags ? tags.to_h : {}
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module EventMetadata
|
|
5
|
+
INTERNAL_TAG_KEYS = %w[
|
|
6
|
+
cache_creation_input_tokens
|
|
7
|
+
cache_creation_tokens
|
|
8
|
+
cache_read_input_tokens
|
|
9
|
+
cache_read_tokens
|
|
10
|
+
cached_input_tokens
|
|
11
|
+
input_tokens
|
|
12
|
+
output_tokens
|
|
13
|
+
reasoning_tokens
|
|
14
|
+
total_tokens
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def usage_data(input_tokens, output_tokens, metadata)
|
|
19
|
+
cache_read_input_tokens = integer_metadata(metadata, :cache_read_input_tokens, :cache_read_tokens)
|
|
20
|
+
cache_creation_input_tokens = integer_metadata(
|
|
21
|
+
metadata,
|
|
22
|
+
:cache_creation_input_tokens,
|
|
23
|
+
:cache_creation_tokens
|
|
24
|
+
)
|
|
25
|
+
cached_input_tokens = integer_metadata(metadata, :cached_input_tokens)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
input_tokens: input_tokens.to_i,
|
|
29
|
+
output_tokens: output_tokens.to_i,
|
|
30
|
+
cached_input_tokens: cached_input_tokens,
|
|
31
|
+
cache_read_input_tokens: cache_read_input_tokens,
|
|
32
|
+
cache_creation_input_tokens: cache_creation_input_tokens,
|
|
33
|
+
total_tokens: input_tokens.to_i + output_tokens.to_i +
|
|
34
|
+
cache_read_input_tokens + cache_creation_input_tokens
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tags(metadata)
|
|
39
|
+
metadata.reject { |key, _value| INTERNAL_TAG_KEYS.include?(key.to_s) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def integer_metadata(metadata, *keys)
|
|
45
|
+
keys.each do |key|
|
|
46
|
+
value = metadata[key] || metadata[key.to_s]
|
|
47
|
+
return value.to_i unless value.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddLatencyMsGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add llm_api_calls.latency_ms"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_latency_ms_to_llm_api_calls.rb.erb",
|
|
18
|
+
"db/migrate/add_latency_ms_to_llm_api_calls.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Generators
|
|
7
|
+
class PricesGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a local LlmCostTracker price override file"
|
|
11
|
+
|
|
12
|
+
def create_prices_file
|
|
13
|
+
template(
|
|
14
|
+
"llm_cost_tracker_prices.yml.erb",
|
|
15
|
+
"config/llm_cost_tracker_prices.yml"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddLatencyMsToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
add_column :llm_api_calls, :latency_ms, :integer unless column_exists?(:llm_api_calls, :latency_ms)
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def down
|
|
7
|
+
remove_column :llm_api_calls, :latency_ms if column_exists?(:llm_api_calls, :latency_ms)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -6,10 +6,15 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
6
6
|
t.integer :input_tokens, null: false, default: 0
|
|
7
7
|
t.integer :output_tokens, null: false, default: 0
|
|
8
8
|
t.integer :total_tokens, null: false, default: 0
|
|
9
|
-
t.decimal :input_cost, precision:
|
|
10
|
-
t.decimal :output_cost, precision:
|
|
11
|
-
t.decimal :total_cost, precision:
|
|
12
|
-
t.
|
|
9
|
+
t.decimal :input_cost, precision: 20, scale: 8
|
|
10
|
+
t.decimal :output_cost, precision: 20, scale: 8
|
|
11
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
12
|
+
t.integer :latency_ms
|
|
13
|
+
if postgresql?
|
|
14
|
+
t.jsonb :tags, null: false, default: {}
|
|
15
|
+
else
|
|
16
|
+
t.text :tags
|
|
17
|
+
end
|
|
13
18
|
t.datetime :tracked_at, null: false
|
|
14
19
|
|
|
15
20
|
t.timestamps
|
|
@@ -19,5 +24,12 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
19
24
|
add_index :llm_api_calls, :model
|
|
20
25
|
add_index :llm_api_calls, :tracked_at
|
|
21
26
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
27
|
+
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def postgresql?
|
|
33
|
+
connection.adapter_name.downcase.include?("postgres")
|
|
22
34
|
end
|
|
23
35
|
end
|
|
@@ -12,6 +12,13 @@ LlmCostTracker.configure do |config|
|
|
|
12
12
|
|
|
13
13
|
# Monthly budget in USD. Set to nil to disable budget alerts.
|
|
14
14
|
# config.monthly_budget = 100.00
|
|
15
|
+
# config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
|
|
16
|
+
|
|
17
|
+
# What to do when storage fails.
|
|
18
|
+
# config.storage_error_behavior = :warn # :ignore, :warn, or :raise
|
|
19
|
+
|
|
20
|
+
# What to do when a model has no built-in price and no pricing_overrides entry.
|
|
21
|
+
# config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
|
|
15
22
|
|
|
16
23
|
# Callback when monthly budget is exceeded.
|
|
17
24
|
# config.on_budget_exceeded = ->(data) {
|
|
@@ -20,8 +27,14 @@ LlmCostTracker.configure do |config|
|
|
|
20
27
|
# # Or send a Slack notification, email, etc.
|
|
21
28
|
# }
|
|
22
29
|
|
|
23
|
-
#
|
|
30
|
+
# Load a local JSON/YAML price table that overrides built-in pricing.
|
|
31
|
+
# config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
|
|
32
|
+
|
|
33
|
+
# Override pricing for specific models in Ruby (per 1M tokens, USD).
|
|
24
34
|
# config.pricing_overrides = {
|
|
25
35
|
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
26
36
|
# }
|
|
37
|
+
|
|
38
|
+
# OpenAI-compatible APIs. OpenRouter and DeepSeek are included by default.
|
|
39
|
+
# config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
27
40
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Local LlmCostTracker price overrides.
|
|
2
|
+
#
|
|
3
|
+
# Add only the models you want to override or add. Built-in prices still come
|
|
4
|
+
# from the gem's prices.json, and Ruby pricing_overrides still take precedence.
|
|
5
|
+
#
|
|
6
|
+
# Units: USD per 1M tokens.
|
|
7
|
+
#
|
|
8
|
+
# Supported price keys:
|
|
9
|
+
# - input
|
|
10
|
+
# - output
|
|
11
|
+
# - cached_input
|
|
12
|
+
# - cache_read_input
|
|
13
|
+
# - cache_creation_input
|
|
14
|
+
#
|
|
15
|
+
# Optional metadata keys, ignored by cost calculation:
|
|
16
|
+
# - _source
|
|
17
|
+
# - _updated
|
|
18
|
+
# - _notes
|
|
19
|
+
#
|
|
20
|
+
# Example: custom fine-tune
|
|
21
|
+
# models:
|
|
22
|
+
# "ft:gpt-4o-mini:my-org":
|
|
23
|
+
# input: 0.30
|
|
24
|
+
# cached_input: 0.15
|
|
25
|
+
# output: 1.20
|
|
26
|
+
# _notes: "Internal fine-tune rate"
|
|
27
|
+
#
|
|
28
|
+
# Example: negotiated provider discount
|
|
29
|
+
# models:
|
|
30
|
+
# "gpt-4o":
|
|
31
|
+
# input: 2.00
|
|
32
|
+
# output: 8.00
|
|
33
|
+
# _source: "Enterprise agreement"
|
|
34
|
+
# _updated: "2026-04-18"
|
|
35
|
+
|
|
36
|
+
models:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
COST_COLUMNS = %i[input_cost output_cost total_cost].freeze
|
|
3
|
+
|
|
4
|
+
def up
|
|
5
|
+
COST_COLUMNS.each do |column|
|
|
6
|
+
change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def down
|
|
11
|
+
COST_COLUMNS.each do |column|
|
|
12
|
+
change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
unless postgresql?
|
|
4
|
+
say "Skipping llm_api_calls.tags JSONB upgrade: database adapter is #{connection.adapter_name}."
|
|
5
|
+
return
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
return if tags_jsonb?
|
|
9
|
+
|
|
10
|
+
remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
|
|
11
|
+
|
|
12
|
+
change_column(
|
|
13
|
+
:llm_api_calls,
|
|
14
|
+
:tags,
|
|
15
|
+
:jsonb,
|
|
16
|
+
using: "CASE WHEN tags IS NULL OR tags = '' THEN '{}'::jsonb ELSE tags::jsonb END",
|
|
17
|
+
default: {},
|
|
18
|
+
null: false
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
add_index :llm_api_calls, :tags, using: :gin unless index_exists?(:llm_api_calls, :tags)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def down
|
|
25
|
+
return unless postgresql?
|
|
26
|
+
|
|
27
|
+
remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
|
|
28
|
+
change_column :llm_api_calls, :tags, :text, using: "tags::text"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def postgresql?
|
|
34
|
+
connection.adapter_name.downcase.include?("postgres")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tags_jsonb?
|
|
38
|
+
column = connection.columns(:llm_api_calls).find { |candidate| candidate.name == "tags" }
|
|
39
|
+
column&.sql_type.to_s.downcase == "jsonb"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeCostPrecisionGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to widen llm_api_calls cost decimal precision"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"upgrade_llm_api_call_cost_precision.rb.erb",
|
|
18
|
+
"db/migrate/upgrade_llm_api_call_cost_precision.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeTagsToJsonbGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to upgrade llm_api_calls.tags to PostgreSQL JSONB"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"upgrade_llm_api_call_tags_to_jsonb.rb.erb",
|
|
18
|
+
"db/migrate/upgrade_llm_api_call_tags_to_jsonb.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -2,15 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
|
|
5
|
+
require_relative "tag_accessors"
|
|
6
|
+
require_relative "tag_query"
|
|
7
|
+
require_relative "tags_column"
|
|
8
|
+
|
|
5
9
|
module LlmCostTracker
|
|
6
10
|
class LlmApiCall < ActiveRecord::Base
|
|
11
|
+
extend TagsColumn
|
|
12
|
+
include TagAccessors
|
|
13
|
+
|
|
7
14
|
self.table_name = "llm_api_calls"
|
|
8
15
|
|
|
9
16
|
# Scopes for querying
|
|
10
17
|
scope :by_provider, ->(provider) { where(provider: provider) }
|
|
11
18
|
scope :by_model, ->(model) { where(model: model) }
|
|
12
|
-
scope :
|
|
13
|
-
|
|
19
|
+
scope :by_user, ->(user_id) { by_tag("user_id", user_id) }
|
|
20
|
+
scope :by_feature, ->(feature) { by_tag("feature", feature) }
|
|
21
|
+
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
22
|
+
scope :without_cost, -> { where(total_cost: nil) }
|
|
23
|
+
scope :unknown_pricing, -> { without_cost }
|
|
24
|
+
scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
|
|
25
|
+
|
|
26
|
+
scope :with_json_tags, lambda {
|
|
27
|
+
if tags_json_column?
|
|
28
|
+
where.not(tags: {})
|
|
29
|
+
else
|
|
30
|
+
where.not(tags: [nil, "", "{}"])
|
|
31
|
+
end
|
|
14
32
|
}
|
|
15
33
|
|
|
16
34
|
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
@@ -18,6 +36,14 @@ module LlmCostTracker
|
|
|
18
36
|
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
19
37
|
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
20
38
|
|
|
39
|
+
def self.by_tag(key, value)
|
|
40
|
+
by_tags(key => value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.by_tags(tags)
|
|
44
|
+
TagQuery.apply(self, tags)
|
|
45
|
+
end
|
|
46
|
+
|
|
21
47
|
# Aggregations
|
|
22
48
|
def self.total_cost
|
|
23
49
|
sum(:total_cost).to_f
|
|
@@ -35,24 +61,29 @@ module LlmCostTracker
|
|
|
35
61
|
group(:provider).sum(:total_cost)
|
|
36
62
|
end
|
|
37
63
|
|
|
38
|
-
def self.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
def self.average_latency_ms
|
|
65
|
+
return nil unless latency_column?
|
|
66
|
+
|
|
67
|
+
average(:latency_ms)&.to_f
|
|
42
68
|
end
|
|
43
69
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
def self.latency_by_model
|
|
71
|
+
return {} unless latency_column?
|
|
72
|
+
|
|
73
|
+
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
48
74
|
end
|
|
49
75
|
|
|
50
|
-
def
|
|
51
|
-
|
|
76
|
+
def self.latency_by_provider
|
|
77
|
+
return {} unless latency_column?
|
|
78
|
+
|
|
79
|
+
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
52
80
|
end
|
|
53
81
|
|
|
54
|
-
def
|
|
55
|
-
|
|
82
|
+
def self.daily_costs(days: 30)
|
|
83
|
+
where(tracked_at: days.days.ago..)
|
|
84
|
+
.group("DATE(tracked_at)")
|
|
85
|
+
.sum(:total_cost)
|
|
86
|
+
.transform_keys(&:to_s)
|
|
56
87
|
end
|
|
57
88
|
end
|
|
58
89
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Logging
|
|
5
|
+
PREFIX = "[LlmCostTracker]"
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def debug(message)
|
|
9
|
+
log(:debug, message)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def info(message)
|
|
13
|
+
log(:info, message)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def warn(message)
|
|
17
|
+
log(:warn, message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def log(level, message)
|
|
21
|
+
message = prefixed(message)
|
|
22
|
+
|
|
23
|
+
if rails_logger
|
|
24
|
+
rails_logger.public_send(level, message)
|
|
25
|
+
else
|
|
26
|
+
Kernel.warn(message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def prefixed(message)
|
|
33
|
+
message = message.to_s
|
|
34
|
+
return message if message.start_with?(PREFIX)
|
|
35
|
+
|
|
36
|
+
"#{PREFIX} #{message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rails_logger
|
|
40
|
+
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|