llm_cost_tracker 0.14.0 → 0.14.1
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 +46 -0
- data/README.md +1 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
- data/app/models/llm_cost_tracker/call.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +24 -21
- data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
- data/app/views/llm_cost_tracker/tags/show.html.erb +3 -0
- data/config/routes.rb +6 -1
- data/lib/llm_cost_tracker/budget/per_tag.rb +8 -4
- data/lib/llm_cost_tracker/capture/event_window.rb +99 -0
- data/lib/llm_cost_tracker/capture/sse.rb +108 -32
- data/lib/llm_cost_tracker/capture/stream_collector.rb +17 -56
- data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +5 -35
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -0
- data/lib/llm_cost_tracker/errors.rb +13 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -6
- data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
- data/lib/llm_cost_tracker/integrations/anthropic.rb +7 -1
- data/lib/llm_cost_tracker/integrations/base.rb +17 -1
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +6 -1
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +53 -18
- data/lib/llm_cost_tracker/ledger/isolation.rb +30 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +2 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +12 -4
- data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
- data/lib/llm_cost_tracker/ledger/store.rb +12 -11
- data/lib/llm_cost_tracker/ledger.rb +1 -0
- data/lib/llm_cost_tracker/logging.rb +3 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +32 -43
- data/lib/llm_cost_tracker/parsers.rb +5 -1
- data/lib/llm_cost_tracker/prices.json +878 -139
- data/lib/llm_cost_tracker/pricing/registry.rb +12 -1
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +30 -13
- data/lib/llm_cost_tracker/providers/azure/parser.rb +19 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +4 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -1
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +10 -5
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +4 -0
- data/lib/llm_cost_tracker/redaction.rb +32 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
- data/lib/llm_cost_tracker/tracker.rb +3 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +4 -2
- data/lib/tasks/llm_cost_tracker.rake +6 -4
- metadata +16 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af338b0d20bfcbdfd9b95a88f903fa2a6b22dab16c72857a48bb2ff6ac0e9ac6
|
|
4
|
+
data.tar.gz: e2688d3cc3d29795787602ccf79b70dbe541347884644b3d5d234d1635f3f4b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4e06cd0bab7dd941fb958ca5fff5b6844ba17879f04bd3370258864e7ebd390088e091d2e0bd7f6fd839fcf05ecf3bc79f954b918c5b0b1cea471e1d14a413b
|
|
7
|
+
data.tar.gz: 107d59de744aabfbf946cbc38ce9ad705b4562a516446163dfb4834102ea263065d67e72c302c75f90ac887c12dfe11c404e85256e46300cd5a2ebe73b61b3af
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.14.1] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `bin/rails llm_cost_tracker:doctor` and the boot log warn when an instrumented SDK is newer than its tested range (RubyLLM 3.0 and later), instead of the integration reporting installed while it records nothing.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Ruby 3.3 is supported; the minimum drops from 3.4.
|
|
14
|
+
- The Faraday middleware adds `stream_options: { include_usage: true }` only for OpenAI, OpenRouter, DeepSeek, Groq, and Azure OpenAI on the v1 API or `api-version` 2024-06-01 and later without On Your Data or image input, since other servers can reject it and fail the request. Hosts you add to `config.capture.openai_compatible_providers` are left untouched: set the flag yourself if they support it, or their streams record `usage_source: unknown` with a warning.
|
|
15
|
+
- `bin/rails llm_cost_tracker:prices:refresh` refuses a snapshot that zeroes an existing price or charges for a free one, drops a model's `input` or `output` rate, moves a price 100-fold or more, or switches currency, and leaves your pricing file untouched, so one bad commit to the snapshot can no longer silence budgets or make `:block_requests` block every call. `PREVIEW=1` and `prices:check` list the changes; re-run with `FORCE=1` (or `force: true`) to accept them. New models and smaller changes are not checked, so keep reviewing the refreshed file.
|
|
16
|
+
- `pricing.unknown_model_behavior = :raise` records the call, with `cost_status: unknown`, before it raises `LlmCostTracker::UnknownPricingError`, so a call the provider already billed is no longer missing from the ledger.
|
|
17
|
+
- A malformed `pricing.file` fails `LlmCostTracker.configure` at boot instead of every LLM call. A missing one is logged at boot and reported as an error by `doctor`, and calls use bundled prices until `prices:refresh` creates it; before, every call raised and nothing was recorded.
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- An OpenAI or Anthropic SDK stream that your code never iterates is no longer recorded at garbage collection; the finalizer that did it kept every stream alive until process exit.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Per-tag `:block_requests` budgets check tags passed to the Faraday middleware (`f.use :llm_cost_tracker, tags: ...`) before the call is sent; they saw only `with_tags` tags, so an over-budget tenant still reached the provider.
|
|
26
|
+
- RubyLLM transcriptions are priced at the model's audio input rate when it has one; they were priced as text, at a third to a half of the real cost.
|
|
27
|
+
- `RubyLLM.transcribe` with a Gemini or Vertex AI model on RubyLLM 1.x is recorded; RubyLLM 1.x sent it through its own Gemini method, bypassing the wrapped `RubyLLM::Provider#transcribe`.
|
|
28
|
+
- Calls made through RubyLLM 2.x are recorded. RubyLLM 2.0 moved token counts behind `response.tokens`, so every call was dropped with a warning while `doctor` reported the integration installed. A 2.x `paint(count:)` returning several images is recorded once, as RubyLLM bills it.
|
|
29
|
+
- An automatically captured LLM call no longer fails, losing the provider's response, when the gem cannot record it, such as when the async inbox pool times out. Only `BudgetExceededError`, `UnknownPricingError` under `:raise`, and `TransactionAbortedError` reach your code; other recording failures are logged. `LlmCostTracker.track` and `track_stream` still raise them.
|
|
30
|
+
- A recording error no longer replaces an exception your code raised while iterating an OpenAI or Anthropic SDK stream or inside `track_stream`, such as `Sidekiq::Shutdown`; the stream is still recorded, tagged `stream_errored`, and the recording error is logged. `TransactionAbortedError` still wins, with your exception as its cause.
|
|
31
|
+
- An OpenAI or Anthropic batch with an unpriced model under `:raise`, or one crossing a budget, records every result before raising once; results after the failing one were lost and `batches.retrieve` raised on every poll.
|
|
32
|
+
- With `ingestion.mode = :async`, one inbox row the database rejects no longer fails its whole batch, which after five attempts quarantined up to 99 other calls and dropped them from the ledger and budget totals. A failed batch is retried one row at a time and only rejected rows are marked failed. Rows already quarantined this way can be requeued as described in `docs/operations.md`.
|
|
33
|
+
- A NUL byte in a stored string, which PostgreSQL rejects, or invalid UTF-8 in a tag value no longer loses the call, and `LlmCostTracker.with_tags` no longer raises on invalid UTF-8. NUL bytes are removed on write, including from inbox rows written by earlier releases, and invalid UTF-8 in tag values becomes U+FFFD.
|
|
34
|
+
- An interrupted Gemini or Azure stream records its model from the request URL instead of `unknown`.
|
|
35
|
+
- The async inbox's `last_error` is no longer empty when the 1,000-byte cut splits a multibyte character.
|
|
36
|
+
- An LLM call inside your own database transaction no longer breaks it when the ledger write fails; on PostgreSQL your app's writes were lost with `PG::InFailedSqlTransaction`. Ledger writes, budget reads, and batch de-duplication run in a savepoint inside an open transaction. On MySQL, where a deadlock rolls back your whole transaction, the gem raises `LlmCostTracker::TransactionAbortedError` instead of swallowing it.
|
|
37
|
+
- With `config.budgets.totals_source = :cache`, the rollup increment inside your transaction waits for its commit on Rails 7.2 and later, so an open transaction no longer holds the rollup row lock and stalls every request recording spend for the same provider. On Rails 7.1 and in non-joinable transactions such as transactional fixtures it still runs immediately, and it is never retried inside a transaction.
|
|
38
|
+
- Long streams record their real usage instead of zero tokens and `$0`. Streams were buffered up to 1 MB, dropping the final usage event (past about 3,000 output tokens through Faraday); they are now decoded as they arrive, keeping only the first and last events plus billable tool-call and grounding events, so memory stays flat at any length. Image streams through Faraday keep their final usage too.
|
|
39
|
+
- Streams captured through the OpenAI and Anthropic SDK integrations are released once your code drops them, instead of staying in memory with their buffered events and request for the life of the process.
|
|
40
|
+
- OpenAI Realtime cached audio is no longer billed twice, at the cache-read rate and again at the audio rate ($0.258 instead of $0.106 for a 10,000-token `gpt-realtime` turn with 5,000 cached audio tokens). Cached audio and image tokens are priced at the model's cache-read rate, below OpenAI's published cached-audio price on the mini models. OpenAI-compatible usage whose audio and image buckets exceed the reported input has the overlap taken out of audio input, then image input.
|
|
41
|
+
- Dashboard links and filter forms carry only the dashboard's own query parameters. They copied every parameter, and Rails reads some as link options, so a crafted link such as `?script_name=//evil.example` pointed Export CSV, pagination, filter, and tag links at another site, others rewrote links or caused a 500, and a 50 KB junk parameter grew a tag page to 10 MB. A query string over 16 KB is a bad request.
|
|
42
|
+
- A dashboard page accepts at most 10 `tag[...]` filters, counting a tag value page's own value, and answers more with a bad request. Each filter adds a subquery, so PostgreSQL planning memory grew with the square of their count, and around 700 got the backend killed for running out of memory, restarting the database. Tag breakdowns no longer offer drill-down links past the limit.
|
|
43
|
+
- Dashboard filters handle bad input without a 500. A list or hash in a tag, provider, model, stream, or usage-source filter is a bad request instead of silently matching nothing, and a NUL byte in a filter value matches nothing on PostgreSQL instead of raising. A CSV export with an invalid filter, a missing call, or a database error renders an HTML error page with the right status instead of a 500 or HTML labelled as CSV. A tag value page's value overrides a filter on the same key, and tag pages for keys containing a dot, such as `/tags/team.name`, load instead of returning 404 or 406.
|
|
44
|
+
- The Pricing page shows the active price source instead of a 500 when `source` is a list or hash, as in `/pricing?source[]=bundled`; a list or hash `provider` filter is a bad request, as on other pages.
|
|
45
|
+
|
|
46
|
+
### Security
|
|
47
|
+
|
|
48
|
+
- From v0.9.0 through v0.14.0, a provider API key sent as a URL parameter could be stored in call tags. When a middleware after `f.use :llm_cost_tracker`, such as `f.response :raise_error`, raised on a streamed Gemini `?key=` or Azure `?api-key=` request, the `stream_interrupted_error` tag stored Faraday's error message with the full URL, shown on the dashboard, in CSV exports, and in notification payloads. The tag now holds only the error class, and the HTTP status goes into a new `stream_interrupted_status` tag. Rotate any key that may have been exposed, and clear stored messages with `UPDATE llm_cost_tracker_call_tags SET value = split_part(value, ': ', 1) WHERE key = 'stream_interrupted_error'` on PostgreSQL or ``UPDATE llm_cost_tracker_call_tags SET value = SUBSTRING_INDEX(value, ': ', 1) WHERE `key` = 'stream_interrupted_error'`` on MySQL. With async ingestion, run it after the inbox drains, and delete quarantined inbox rows whose `payload` contains `stream_interrupted_error`.
|
|
49
|
+
- Provider keys, tokens, `key=`/`token=`/`sig=` URL parameters, URL user info, and `Authorization` headers inside a longer string are replaced with `[REDACTED]` in tag values, log lines, the async inbox's `last_error`, `TransactionAbortedError` messages, and the `source_url` that `llm_cost_tracker:prices:refresh` writes and prints; before, only a tag value that was entirely a key was caught.
|
|
50
|
+
|
|
5
51
|
## [0.14.0] - 2026-08-26
|
|
6
52
|
|
|
7
53
|
### Added
|
data/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Every call your app makes through RubyLLM, the official OpenAI and Anthropic SDK
|
|
|
8
8
|
|
|
9
9
|
Not Langfuse, Helicone, or LiteLLM. No prompts, no traces, no replay. Spend attribution only.
|
|
10
10
|
|
|
11
|
-
Requires Ruby 3.
|
|
11
|
+
Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL.
|
|
12
12
|
|
|
13
13
|
<picture> <source media="(prefers-color-scheme: dark)" srcset="docs/dashboard-overview-dark.png"> <img alt="LLM Cost Tracker dashboard" src="docs/dashboard-overview-light.png"> </picture>
|
|
14
14
|
|
|
@@ -4,11 +4,14 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
class ApplicationController < ActionController::Base
|
|
7
|
+
MAX_QUERY_BYTES = 16 * 1024
|
|
8
|
+
|
|
7
9
|
layout "llm_cost_tracker/application"
|
|
8
10
|
|
|
9
11
|
protect_from_forgery with: :exception
|
|
10
12
|
|
|
11
13
|
before_action :set_dashboard_security_headers
|
|
14
|
+
before_action :reject_oversized_query
|
|
12
15
|
before_action :ensure_current_schema
|
|
13
16
|
before_action :assign_dashboard_date_range
|
|
14
17
|
|
|
@@ -22,6 +25,12 @@ module LlmCostTracker
|
|
|
22
25
|
|
|
23
26
|
private
|
|
24
27
|
|
|
28
|
+
def reject_oversized_query
|
|
29
|
+
return if request.query_string.bytesize <= MAX_QUERY_BYTES
|
|
30
|
+
|
|
31
|
+
raise LlmCostTracker::InvalidFilterError, "query string exceeds #{MAX_QUERY_BYTES / 1024} KB"
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def ensure_current_schema
|
|
26
35
|
drift = LlmCostTracker::Dashboard::SetupState.current
|
|
27
36
|
return unless drift
|
|
@@ -40,16 +49,20 @@ module LlmCostTracker
|
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def render_database_error(_error)
|
|
43
|
-
|
|
52
|
+
render_error_page("database", :internal_server_error)
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
def render_invalid_filter(error)
|
|
47
56
|
@error_message = error.message
|
|
48
|
-
|
|
57
|
+
render_error_page("invalid_filter", :bad_request)
|
|
49
58
|
end
|
|
50
59
|
|
|
51
60
|
def render_not_found
|
|
52
|
-
|
|
61
|
+
render_error_page("not_found", :not_found)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_error_page(name, status)
|
|
65
|
+
render "llm_cost_tracker/errors/#{name}", status: status, formats: :html, content_type: "text/html"
|
|
53
66
|
end
|
|
54
67
|
|
|
55
68
|
def set_dashboard_security_headers
|
|
@@ -4,10 +4,10 @@ module LlmCostTracker
|
|
|
4
4
|
class PricingController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@overview = Dashboard::PricingOverview.call
|
|
7
|
-
requested = params[:source]
|
|
7
|
+
requested = params[:source].to_s.to_sym
|
|
8
8
|
@active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
|
|
9
9
|
@source_data = @overview.fetch(:sources).fetch(@active_source)
|
|
10
|
-
@provider_filter = params[:provider].
|
|
10
|
+
@provider_filter = Dashboard::Params.scalar(params[:provider], :provider).presence
|
|
11
11
|
@rows = @source_data.fetch(:rows)
|
|
12
12
|
@rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
|
|
13
13
|
@providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
|
|
@@ -7,19 +7,20 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
|
|
11
|
-
@value = params[:tag_value].to_s
|
|
10
|
+
@value = Dashboard::Params.scalar(params[:tag_value], :tag_value)
|
|
12
11
|
|
|
13
12
|
if @value.empty?
|
|
14
13
|
@sort = params[:sort].to_s
|
|
15
14
|
@dir = params[:dir].to_s
|
|
16
|
-
@breakdown = Dashboard::TagBreakdown.call(
|
|
15
|
+
@breakdown = Dashboard::TagBreakdown.call(
|
|
16
|
+
scope: Dashboard::Filter.call(params: params), key: params[:key], sort: @sort, direction: @dir
|
|
17
|
+
)
|
|
17
18
|
else
|
|
18
19
|
@key = LlmCostTracker::Tags::Key.validate!(
|
|
19
20
|
params[:key],
|
|
20
21
|
error_class: LlmCostTracker::InvalidFilterError
|
|
21
22
|
)
|
|
22
|
-
value_scope =
|
|
23
|
+
value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
|
|
23
24
|
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
24
25
|
@value_calls = value_scope.count
|
|
25
26
|
@value_points = Dashboard::TimeSeries.call(scope: value_scope)
|
|
@@ -135,7 +135,7 @@ module LlmCostTracker
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def current_query(overrides = {})
|
|
138
|
-
request.query_parameters.symbolize_keys.merge(overrides)
|
|
138
|
+
request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
def calls_query_for_model(provider:, model:)
|
|
@@ -16,6 +16,11 @@ module LlmCostTracker
|
|
|
16
16
|
query
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def tag_drilldown_allowed?(key)
|
|
20
|
+
tags = LlmCostTracker::Dashboard::Params.tag_query(current_query[:tag])
|
|
21
|
+
tags.except(key.to_s).size < LlmCostTracker::Dashboard::Filter::MAX_TAG_FILTERS
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
def hidden_query_fields(query, prefix: nil)
|
|
20
25
|
safe_join(query.flat_map do |key, value|
|
|
21
26
|
name = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
@@ -45,7 +45,7 @@ module LlmCostTracker
|
|
|
45
45
|
def already_recorded?(provider:, provider_response_id:)
|
|
46
46
|
return false if provider_response_id.to_s.empty?
|
|
47
47
|
|
|
48
|
-
where(provider: provider, provider_response_id: provider_response_id).exists?
|
|
48
|
+
Ledger::Isolation.guard(self) { where(provider: provider, provider_response_id: provider_response_id).exists? }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def by_tag(key, value) = by_tags(key => value)
|
|
@@ -5,20 +5,23 @@ require "date"
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Dashboard
|
|
7
7
|
class Filter
|
|
8
|
+
MAX_TAG_FILTERS = 10
|
|
9
|
+
MATCH_NOTHING = Arel.sql("1 = 0")
|
|
8
10
|
STREAM_FILTER_OPTIONS = [
|
|
9
11
|
["Streaming only", "yes"],
|
|
10
12
|
["Non-streaming only", "no"]
|
|
11
13
|
].freeze
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
|
-
def call(scope: LlmCostTracker::Call.all, params: {})
|
|
15
|
-
new(scope: scope, params: params).relation
|
|
16
|
+
def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
|
|
17
|
+
new(scope: scope, params: params, tags: tags).relation
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def initialize(scope:, params:)
|
|
21
|
+
def initialize(scope:, params:, tags: {})
|
|
20
22
|
@scope = scope
|
|
21
23
|
@params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
|
|
24
|
+
@extra_tags = tags
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def relation
|
|
@@ -27,13 +30,13 @@ module LlmCostTracker
|
|
|
27
30
|
filtered_scope = apply_exact_filter(filtered_scope, :provider)
|
|
28
31
|
filtered_scope = apply_exact_filter(filtered_scope, :model)
|
|
29
32
|
filtered_scope = apply_stream_filter(filtered_scope)
|
|
30
|
-
filtered_scope =
|
|
33
|
+
filtered_scope = apply_exact_filter(filtered_scope, :usage_source)
|
|
31
34
|
apply_tag_filters(filtered_scope)
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
private
|
|
35
38
|
|
|
36
|
-
attr_reader :scope, :params
|
|
39
|
+
attr_reader :scope, :params, :extra_tags
|
|
37
40
|
|
|
38
41
|
def apply_date_filters(relation)
|
|
39
42
|
from_date = Dashboard::DateRange.parse(params, :from)
|
|
@@ -50,21 +53,25 @@ module LlmCostTracker
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def apply_exact_filter(relation, key)
|
|
53
|
-
value = normalized_string(params[key])
|
|
56
|
+
value = normalized_string(params[key], key)
|
|
54
57
|
return relation if value.nil?
|
|
55
58
|
|
|
56
|
-
relation.where(key => value)
|
|
59
|
+
matchable?([value]) ? relation.where(key => value) : relation.where(MATCH_NOTHING)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
def apply_tag_filters(relation)
|
|
60
|
-
tags = tag_params
|
|
63
|
+
tags = tag_params.merge(extra_tags)
|
|
61
64
|
return relation if tags.empty?
|
|
65
|
+
if tags.size > MAX_TAG_FILTERS
|
|
66
|
+
raise LlmCostTracker::InvalidFilterError,
|
|
67
|
+
"at most #{MAX_TAG_FILTERS} tag filters are allowed, got #{tags.size}"
|
|
68
|
+
end
|
|
62
69
|
|
|
63
|
-
relation.by_tags(tags)
|
|
70
|
+
matchable?(tags.values) ? relation.by_tags(tags) : relation.where(MATCH_NOTHING)
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def apply_stream_filter(relation)
|
|
67
|
-
value = normalized_string(params[:stream])
|
|
74
|
+
value = normalized_string(params[:stream], :stream)
|
|
68
75
|
return relation if value.nil?
|
|
69
76
|
|
|
70
77
|
case value.downcase
|
|
@@ -74,28 +81,24 @@ module LlmCostTracker
|
|
|
74
81
|
end
|
|
75
82
|
end
|
|
76
83
|
|
|
77
|
-
def apply_usage_source_filter(relation)
|
|
78
|
-
value = normalized_string(params[:usage_source])
|
|
79
|
-
return relation if value.nil?
|
|
80
|
-
|
|
81
|
-
relation.where(usage_source: value)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
84
|
def tag_params
|
|
85
85
|
tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
|
|
86
86
|
|
|
87
87
|
tags.each_with_object({}) do |(key, value), normalized|
|
|
88
|
-
value = normalized_string(value)
|
|
88
|
+
value = normalized_string(value, "tag filter value")
|
|
89
89
|
next if value.nil?
|
|
90
90
|
|
|
91
91
|
normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
def
|
|
96
|
-
|
|
95
|
+
def matchable?(values)
|
|
96
|
+
values.none? { |value| value.include?("\0") } ||
|
|
97
|
+
!LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
|
|
98
|
+
end
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
def normalized_string(value, name)
|
|
101
|
+
LlmCostTracker::Dashboard::Params.scalar(value, name).strip.presence
|
|
99
102
|
end
|
|
100
103
|
end
|
|
101
104
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Dashboard
|
|
5
5
|
module Params
|
|
6
|
+
QUERY_KEYS = %i[from to provider model tag tag_value stream usage_source cost_status sort dir page per].freeze
|
|
7
|
+
|
|
6
8
|
class << self
|
|
7
9
|
def to_hash(value)
|
|
8
10
|
return {} if value.nil?
|
|
@@ -17,6 +19,14 @@ module LlmCostTracker
|
|
|
17
19
|
{}
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
def scalar(value, name)
|
|
23
|
+
if value.is_a?(Hash) || value.is_a?(Array) || value.respond_to?(:to_unsafe_h)
|
|
24
|
+
raise InvalidFilterError, "#{name} must be a single value"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
value.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def tag_query(value)
|
|
21
31
|
to_hash(value).each_with_object({}) do |(key, tag_value), tags|
|
|
22
32
|
key = key.to_s
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
</tr>
|
|
108
108
|
</thead>
|
|
109
109
|
<tbody>
|
|
110
|
+
<% drilldown_allowed = tag_drilldown_allowed?(params[:key]) %>
|
|
110
111
|
<% @breakdown.rows.each do |row| %>
|
|
111
112
|
<tr>
|
|
112
113
|
<td><code class="lct-code-id"><%= row.value %></code></td>
|
|
@@ -117,6 +118,8 @@
|
|
|
117
118
|
<td class="lct-num">
|
|
118
119
|
<% if row.value == "(untagged)" %>
|
|
119
120
|
<span class="lct-num-muted">n/a</span>
|
|
121
|
+
<% elsif !drilldown_allowed %>
|
|
122
|
+
<span class="lct-num-muted">tag filter limit</span>
|
|
120
123
|
<% else %>
|
|
121
124
|
<%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
|
|
122
125
|
<%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
|
data/config/routes.rb
CHANGED
|
@@ -4,7 +4,12 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
4
4
|
root "dashboard#index"
|
|
5
5
|
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
6
|
resources :models, only: :index
|
|
7
|
-
resources :tags,
|
|
7
|
+
resources :tags,
|
|
8
|
+
only: %i[index show],
|
|
9
|
+
param: :key,
|
|
10
|
+
format: false,
|
|
11
|
+
constraints: { key: %r{[^/]+} },
|
|
12
|
+
defaults: { format: :html }
|
|
8
13
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
9
14
|
get "pricing", to: "pricing#index", as: :pricing
|
|
10
15
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../ledger/isolation"
|
|
3
4
|
require_relative "../ledger/schema/adapter"
|
|
4
5
|
require_relative "../ledger/tags/encoding"
|
|
5
6
|
|
|
@@ -63,9 +64,11 @@ module LlmCostTracker
|
|
|
63
64
|
|
|
64
65
|
def spend_by_value(key, values, window, bucket)
|
|
65
66
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
-
totals = LlmCostTracker::CallTag
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
|
|
68
|
+
LlmCostTracker::CallTag
|
|
69
|
+
.where(key: key, value: values, TIME_COLUMN => window_range(window, bucket))
|
|
70
|
+
.group(:value).sum(COST_COLUMN)
|
|
71
|
+
end
|
|
69
72
|
warn_slow_read(key, window, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
|
|
70
73
|
totals
|
|
71
74
|
end
|
|
@@ -88,7 +91,8 @@ module LlmCostTracker
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
def columns?
|
|
91
|
-
|
|
94
|
+
LlmCostTracker::CallTag.table_exists? &&
|
|
95
|
+
(LlmCostTracker::CallTag.column_names & [COST_COLUMN, TIME_COLUMN]).size == 2
|
|
92
96
|
end
|
|
93
97
|
|
|
94
98
|
private
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sse"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Capture
|
|
7
|
+
class EventWindow
|
|
8
|
+
HEAD_EVENTS = 16
|
|
9
|
+
TAIL_EVENTS = 32
|
|
10
|
+
HEAVY_STRING_BYTES = 8 * 1024
|
|
11
|
+
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(notable: nil)
|
|
14
|
+
@notable = notable
|
|
15
|
+
@head = []
|
|
16
|
+
@kept = []
|
|
17
|
+
@tail = []
|
|
18
|
+
@bytes = 0
|
|
19
|
+
@overflowed = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def push(data, type: nil)
|
|
23
|
+
return if @overflowed
|
|
24
|
+
|
|
25
|
+
event = { event: type, data: strip_heavy_payload(data) }
|
|
26
|
+
size = approximate_bytesize(event)
|
|
27
|
+
if @head.size < HEAD_EVENTS
|
|
28
|
+
@head << [event, size]
|
|
29
|
+
else
|
|
30
|
+
@tail << [event, size]
|
|
31
|
+
settle(@tail.shift) if @tail.size > TAIL_EVENTS
|
|
32
|
+
end
|
|
33
|
+
@bytes += size
|
|
34
|
+
overflow! if @bytes > SSE::LIMIT_BYTES
|
|
35
|
+
rescue TypeError, SystemStackError
|
|
36
|
+
overflow!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def events
|
|
40
|
+
(@head + @kept + @tail).map(&:first)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def overflowed?
|
|
44
|
+
@overflowed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def settle(entry)
|
|
50
|
+
return @kept << entry if notable?(entry.first[:data])
|
|
51
|
+
|
|
52
|
+
@bytes -= entry.last
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def notable?(data)
|
|
56
|
+
@notable&.call(data)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def overflow!
|
|
62
|
+
@overflowed = true
|
|
63
|
+
@head = []
|
|
64
|
+
@kept = []
|
|
65
|
+
@tail = []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def strip_heavy_payload(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash
|
|
71
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
72
|
+
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
73
|
+
|
|
74
|
+
out[key] = strip_heavy_payload(nested)
|
|
75
|
+
end
|
|
76
|
+
when Array
|
|
77
|
+
value.map { |nested| strip_heavy_payload(nested) }
|
|
78
|
+
when String
|
|
79
|
+
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
80
|
+
else
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def approximate_bytesize(value)
|
|
86
|
+
case value
|
|
87
|
+
when Hash
|
|
88
|
+
value.sum { |key, nested| approximate_bytesize(key) + approximate_bytesize(nested) + 4 }
|
|
89
|
+
when Array
|
|
90
|
+
value.sum { |nested| approximate_bytesize(nested) + 2 }
|
|
91
|
+
when Numeric, true, false, nil
|
|
92
|
+
8
|
|
93
|
+
else
|
|
94
|
+
value.to_s.bytesize + 2
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|