llm_cost_tracker 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +4 -1
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/budget.rb +1 -2
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
- data/lib/llm_cost_tracker/configuration.rb +3 -20
- data/lib/llm_cost_tracker/doctor.rb +74 -8
- data/lib/llm_cost_tracker/event.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
- data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers.rb +0 -2
- data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
- data/lib/llm_cost_tracker/pricing.rb +1 -18
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -4
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/tracker.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +14 -6
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<% if chips.any? %>
|
|
2
|
-
<div class="lct-chip-row" aria-label="Active filters">
|
|
3
|
-
<% chips.each do |chip| %>
|
|
4
|
-
<span class="lct-chip">
|
|
5
|
-
<span class="lct-chip-label"><%= chip[:label] %></span>
|
|
6
|
-
<span><%= chip[:value] %></span>
|
|
7
|
-
<% if chip[:path] %>
|
|
8
|
-
<%= link_to "×", chip[:path], class: "lct-chip-remove", aria: { label: "Remove #{chip[:label]} #{chip[:value]}" } %>
|
|
9
|
-
<% end %>
|
|
10
|
-
</span>
|
|
11
|
-
<% end %>
|
|
12
|
-
<% if local_assigns[:clear_path] %>
|
|
13
|
-
<%= link_to "Clear all", clear_path, class: "lct-clear-link" %>
|
|
14
|
-
<% end %>
|
|
15
|
-
</div>
|
|
16
|
-
<% end %>
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
<%
|
|
2
|
-
fields = local_assigns.fetch(:fields, %i[from to provider model stream])
|
|
3
|
-
default_range = LlmCostTracker::Dashboard::DateRange.call(params: params)
|
|
4
|
-
defaults = {
|
|
5
|
-
from: default_range.from.iso8601,
|
|
6
|
-
to: default_range.to.iso8601
|
|
7
|
-
}.merge(local_assigns.fetch(:defaults, {}))
|
|
8
|
-
reset_path = local_assigns.fetch(:reset_path, path)
|
|
9
|
-
filter_scope = local_assigns.fetch(:filter_scope, params)
|
|
10
|
-
%>
|
|
11
|
-
|
|
12
|
-
<form class="lct-filters" action="<%= path %>" method="get">
|
|
13
|
-
<% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
|
|
14
|
-
<%= hidden_field_tag(key, val) %>
|
|
15
|
-
<% end %>
|
|
16
|
-
<div class="lct-filter-row">
|
|
17
|
-
<% if fields.include?(:from) %>
|
|
18
|
-
<div class="lct-field">
|
|
19
|
-
<label for="lct-filter-from">From</label>
|
|
20
|
-
<input id="lct-filter-from" type="date" name="from" value="<%= params[:from] || defaults[:from] %>">
|
|
21
|
-
</div>
|
|
22
|
-
<% end %>
|
|
23
|
-
|
|
24
|
-
<% if fields.include?(:to) %>
|
|
25
|
-
<div class="lct-field">
|
|
26
|
-
<label for="lct-filter-to">To</label>
|
|
27
|
-
<input id="lct-filter-to" type="date" name="to" value="<%= params[:to] || defaults[:to] %>">
|
|
28
|
-
</div>
|
|
29
|
-
<% end %>
|
|
30
|
-
|
|
31
|
-
<% if fields.include?(:provider) %>
|
|
32
|
-
<div class="lct-field">
|
|
33
|
-
<label for="lct-filter-provider">Provider</label>
|
|
34
|
-
<%= select_tag :provider,
|
|
35
|
-
options_for_select(provider_filter_options(filter_params: filter_scope), params[:provider]),
|
|
36
|
-
include_blank: "All providers",
|
|
37
|
-
id: "lct-filter-provider" %>
|
|
38
|
-
</div>
|
|
39
|
-
<% end %>
|
|
40
|
-
|
|
41
|
-
<% if fields.include?(:model) %>
|
|
42
|
-
<div class="lct-field">
|
|
43
|
-
<label for="lct-filter-model">Model</label>
|
|
44
|
-
<%= select_tag :model,
|
|
45
|
-
options_for_select(model_filter_options(filter_params: filter_scope), params[:model]),
|
|
46
|
-
include_blank: "All models",
|
|
47
|
-
id: "lct-filter-model" %>
|
|
48
|
-
</div>
|
|
49
|
-
<% end %>
|
|
50
|
-
|
|
51
|
-
<% if fields.include?(:stream) %>
|
|
52
|
-
<div class="lct-field">
|
|
53
|
-
<label for="lct-filter-stream">Stream</label>
|
|
54
|
-
<%= select_tag :stream,
|
|
55
|
-
options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
|
|
56
|
-
include_blank: "All calls",
|
|
57
|
-
id: "lct-filter-stream" %>
|
|
58
|
-
</div>
|
|
59
|
-
<% end %>
|
|
60
|
-
|
|
61
|
-
<div class="lct-filter-actions">
|
|
62
|
-
<button class="lct-button" type="submit">Apply</button>
|
|
63
|
-
<%= link_to("Reset", reset_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
</form>
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<%
|
|
2
|
-
current = local_assigns.fetch(:current).to_s
|
|
3
|
-
options = local_assigns.fetch(:options)
|
|
4
|
-
%>
|
|
5
|
-
|
|
6
|
-
<nav class="lct-sort" aria-label="Sort by">
|
|
7
|
-
<% options.each do |label, value| %>
|
|
8
|
-
<%= link_to label,
|
|
9
|
-
path_for_sort.call(value),
|
|
10
|
-
class: ["lct-sort-option", ("is-active" if current == value.to_s)].compact.join(" "),
|
|
11
|
-
aria: ({ current: "true" } if current == value.to_s) %>
|
|
12
|
-
<% end %>
|
|
13
|
-
</nav>
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../billing/line_item"
|
|
4
|
-
require_relative "../providers/openai/model_families"
|
|
5
|
-
|
|
6
|
-
module LlmCostTracker
|
|
7
|
-
module Parsers
|
|
8
|
-
module OpenaiServiceCharges
|
|
9
|
-
RESPONSE_OUTPUT_COMPONENTS = {
|
|
10
|
-
"web_search_call" => :web_search_request,
|
|
11
|
-
"file_search_call" => :file_search_call,
|
|
12
|
-
"code_interpreter_call" => :container_session,
|
|
13
|
-
"mcp_call" => :mcp_call
|
|
14
|
-
}.freeze
|
|
15
|
-
|
|
16
|
-
module_function
|
|
17
|
-
|
|
18
|
-
def line_items_from_output(output_items, request: nil, model: nil)
|
|
19
|
-
deduped = {}
|
|
20
|
-
Array(output_items).each { |item| store_output_item(deduped, item) }
|
|
21
|
-
deduped.values
|
|
22
|
-
.select { |item| billable?(item) }
|
|
23
|
-
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def service_line_items_for(response, request: nil, model: nil)
|
|
27
|
-
output_items = Array(response["output"])
|
|
28
|
-
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
29
|
-
line_items_from_output(output_items, request: request, model: model)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
|
|
33
|
-
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
|
|
34
|
-
|
|
35
|
-
def chat_completions_web_search_items(response, model: nil)
|
|
36
|
-
return [] unless response["choices"]
|
|
37
|
-
|
|
38
|
-
provider_field = chat_completions_search_provider_field(response["choices"], model)
|
|
39
|
-
return [] unless provider_field
|
|
40
|
-
|
|
41
|
-
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
42
|
-
"provider_field" => provider_field }]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def chat_completions_search_provider_field(choices, model)
|
|
46
|
-
return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
|
|
47
|
-
return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
|
|
48
|
-
|
|
49
|
-
nil
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def chat_completions_used_web_search?(choices)
|
|
53
|
-
Array(choices).any? do |choice|
|
|
54
|
-
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
55
|
-
annotation.is_a?(Hash) && annotation["type"] == "url_citation"
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def billable?(item)
|
|
61
|
-
return false unless item.is_a?(Hash)
|
|
62
|
-
|
|
63
|
-
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
64
|
-
return false unless component
|
|
65
|
-
return true unless component == :web_search_request
|
|
66
|
-
|
|
67
|
-
action_type = item.dig("action", "type")
|
|
68
|
-
action_type.nil? || action_type == "search"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def store_output_item(output_items, item)
|
|
72
|
-
return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
|
|
73
|
-
|
|
74
|
-
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
75
|
-
key = if component == :container_session && item["container_id"]
|
|
76
|
-
"#{component}:#{item['container_id']}"
|
|
77
|
-
else
|
|
78
|
-
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
79
|
-
end
|
|
80
|
-
output_items[key] = item
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def build_line_item(item, request: nil, model: nil)
|
|
84
|
-
return nil unless item.is_a?(Hash)
|
|
85
|
-
|
|
86
|
-
component_key = component_key_for(item, request: request, model: model)
|
|
87
|
-
return nil unless component_key
|
|
88
|
-
|
|
89
|
-
provider_item_id = if component_key == :container_session
|
|
90
|
-
item["container_id"] || item["id"]
|
|
91
|
-
else
|
|
92
|
-
item["id"]
|
|
93
|
-
end
|
|
94
|
-
Billing::LineItem.build(
|
|
95
|
-
component_key: component_key,
|
|
96
|
-
quantity: 1,
|
|
97
|
-
cost_status: Billing::CostStatus::UNKNOWN,
|
|
98
|
-
pricing_basis: :provider_usage,
|
|
99
|
-
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
100
|
-
provider_item_id: provider_item_id,
|
|
101
|
-
details: line_item_details(item)
|
|
102
|
-
)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def component_key_for(item, request:, model:)
|
|
106
|
-
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
107
|
-
return component unless component == :web_search_request
|
|
108
|
-
return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
109
|
-
|
|
110
|
-
reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def web_search_preview_used?(request)
|
|
114
|
-
tools = request && (request[:tools] || request["tools"])
|
|
115
|
-
return false unless tools.respond_to?(:each)
|
|
116
|
-
|
|
117
|
-
tools.any? do |tool|
|
|
118
|
-
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
119
|
-
type.to_s.include?("web_search_preview")
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def chat_completions_search_model?(model)
|
|
124
|
-
return false unless model
|
|
125
|
-
|
|
126
|
-
name = model.to_s.split("/", 2).last
|
|
127
|
-
LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def reasoning_model?(model)
|
|
131
|
-
return false unless model
|
|
132
|
-
|
|
133
|
-
name = model.to_s.split("/", 2).last
|
|
134
|
-
LlmCostTracker::Providers::Openai::ModelFamilies.reasoning?(name)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def line_item_details(item)
|
|
138
|
-
{
|
|
139
|
-
"status" => item["status"],
|
|
140
|
-
"action_type" => item.dig("action", "type"),
|
|
141
|
-
"container_id" => item["container_id"]
|
|
142
|
-
}.compact
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
146
|
-
output_items = []
|
|
147
|
-
each_event_data(events) do |data|
|
|
148
|
-
output_items.concat(Array(data.dig("response", "output")))
|
|
149
|
-
output_items << data["item"] if data["item"]
|
|
150
|
-
end
|
|
151
|
-
line_items_from_output(output_items, request: request, model: model)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|