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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +4 -1
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
  17. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  25. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  26. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  27. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  28. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  33. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  34. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  36. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  37. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  38. data/config/routes.rb +1 -0
  39. data/lib/llm_cost_tracker/budget.rb +1 -2
  40. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
  43. data/lib/llm_cost_tracker/configuration.rb +3 -20
  44. data/lib/llm_cost_tracker/doctor.rb +74 -8
  45. data/lib/llm_cost_tracker/event.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
  49. data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
  50. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
  51. data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
  52. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
  53. data/lib/llm_cost_tracker/ledger.rb +13 -0
  54. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
  55. data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
  56. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
  57. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
  58. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  59. data/lib/llm_cost_tracker/parsers.rb +0 -2
  60. data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
  61. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  62. data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
  63. data/lib/llm_cost_tracker/pricing.rb +1 -18
  64. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  65. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  66. data/lib/llm_cost_tracker/railtie.rb +0 -4
  67. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  68. data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
  69. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
  70. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  71. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  72. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  73. data/lib/llm_cost_tracker/tracker.rb +1 -1
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. metadata +14 -6
  76. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  77. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  78. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  79. 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