token_hawk 0.1.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 +7 -0
- data/CHANGELOG.md +51 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/token_hawk/application.css +106 -0
- data/app/controllers/token_hawk/application_controller.rb +6 -0
- data/app/controllers/token_hawk/dashboard_controller.rb +12 -0
- data/app/controllers/token_hawk/domains_controller.rb +15 -0
- data/app/controllers/token_hawk/pricing_controller.rb +9 -0
- data/app/controllers/token_hawk/recent_controller.rb +12 -0
- data/app/views/layouts/token_hawk/application.html.erb +251 -0
- data/app/views/token_hawk/dashboard/index.html.erb +53 -0
- data/app/views/token_hawk/domains/show.html.erb +48 -0
- data/app/views/token_hawk/pricing/index.html.erb +27 -0
- data/app/views/token_hawk/recent/index.html.erb +36 -0
- data/config/routes.rb +8 -0
- data/db/migrate/01_create_token_hawk_calls.rb +22 -0
- data/db/migrate/02_create_token_hawk_llm_rates.rb +16 -0
- data/docs/cli.md +98 -0
- data/docs/configuration.md +90 -0
- data/docs/dashboard.md +73 -0
- data/docs/subscribe_hooks.md +81 -0
- data/exe/token_hawk +15 -0
- data/lib/generators/token_hawk/install/install_generator.rb +42 -0
- data/lib/generators/token_hawk/install/templates/create_token_hawk_calls.rb +18 -0
- data/lib/generators/token_hawk/install/templates/token_hawk.rb +12 -0
- data/lib/token_hawk/adapters/base.rb +23 -0
- data/lib/token_hawk/call.rb +15 -0
- data/lib/token_hawk/call_record.rb +18 -0
- data/lib/token_hawk/cli/commands/costs.rb +92 -0
- data/lib/token_hawk/cli/commands/efficiency.rb +54 -0
- data/lib/token_hawk/cli/commands/recent.rb +52 -0
- data/lib/token_hawk/cli/formatter.rb +13 -0
- data/lib/token_hawk/cli.rb +34 -0
- data/lib/token_hawk/configuration.rb +19 -0
- data/lib/token_hawk/cost.rb +18 -0
- data/lib/token_hawk/dashboard_query.rb +32 -0
- data/lib/token_hawk/domain_query.rb +35 -0
- data/lib/token_hawk/engine.rb +7 -0
- data/lib/token_hawk/pricing.rb +41 -0
- data/lib/token_hawk/recent_query.rb +16 -0
- data/lib/token_hawk/response_adapter.rb +23 -0
- data/lib/token_hawk/storage/active_record.rb +54 -0
- data/lib/token_hawk/storage/memory.rb +33 -0
- data/lib/token_hawk/version.rb +5 -0
- data/lib/token_hawk.rb +122 -0
- data/sig/token_hawk.rbs +4 -0
- metadata +172 -0
data/docs/dashboard.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Dashboard
|
|
2
|
+
|
|
3
|
+
TokenHawk ships a mountable Rails engine with a read-only browser dashboard.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Mounting
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# config/routes.rb
|
|
11
|
+
mount TokenHawk::Engine, at: "/token_hawk"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Visit `/token_hawk` to see the overview.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Authentication
|
|
19
|
+
|
|
20
|
+
The engine has no built-in authentication — it relies on the host app's. Protect the mount point before deploying to production.
|
|
21
|
+
|
|
22
|
+
**Devise example:**
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
26
|
+
mount TokenHawk::Engine, at: "/token_hawk"
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**HTTP Basic (simple staging protection):**
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/initializers/token_hawk.rb
|
|
34
|
+
TokenHawk::Engine.middleware.use Rack::Auth::Basic, "TokenHawk" do |u, p|
|
|
35
|
+
ActiveSupport::SecurityUtils.secure_compare(p, ENV.fetch("TOKEN_HAWK_PASSWORD"))
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Views
|
|
42
|
+
|
|
43
|
+
### Overview (`/token_hawk`)
|
|
44
|
+
|
|
45
|
+
- Total spend for the current calendar month
|
|
46
|
+
- Top 3 domains by cost
|
|
47
|
+
- Daily cost breakdown for the current month
|
|
48
|
+
|
|
49
|
+
### Domain detail (`/token_hawk/domains/:domain`)
|
|
50
|
+
|
|
51
|
+
- Total cost, call count, and average cost per call
|
|
52
|
+
- Top tag key-value pairs within the domain
|
|
53
|
+
- 10 most recent calls
|
|
54
|
+
|
|
55
|
+
Returns 404 for domains with no recorded calls.
|
|
56
|
+
|
|
57
|
+
### Recent calls (`/token_hawk/recent`)
|
|
58
|
+
|
|
59
|
+
- Reverse-chronological call log
|
|
60
|
+
- Columns: timestamp, domain, model, input tokens, output tokens, cost, latency
|
|
61
|
+
- Supports `?domain=X` query param to filter
|
|
62
|
+
|
|
63
|
+
### Pricing reference (`/token_hawk/pricing`)
|
|
64
|
+
|
|
65
|
+
- All models in the built-in pricing table
|
|
66
|
+
- Input and output rates per 1M tokens
|
|
67
|
+
- Note on rate vintage (rates reflect the gem version release date)
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Styling
|
|
72
|
+
|
|
73
|
+
The engine ships a minimal self-contained stylesheet (`token_hawk/application.css`). It uses system fonts and a neutral palette that won't clash with most host app designs. No Tailwind or external CSS framework required.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Subscribe Hooks
|
|
2
|
+
|
|
3
|
+
TokenHawk fires events after every recorded call. Subscribe to react in real time — ship metrics, trigger alerts, or log to a secondary store.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
TokenHawk.subscribe(:call_recorded) do |call|
|
|
11
|
+
# call is a TokenHawk::Call value object (frozen, immutable)
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Place subscriptions in an initializer so they're registered at boot.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The `Call` object
|
|
20
|
+
|
|
21
|
+
| Attribute | Type | Description |
|
|
22
|
+
|-----------|------|-------------|
|
|
23
|
+
| `domain` | Symbol | Attribution domain |
|
|
24
|
+
| `tags` | Hash | Arbitrary key-value metadata |
|
|
25
|
+
| `vendor` | String | `"anthropic"` or `"openai"` |
|
|
26
|
+
| `model` | String | Model identifier |
|
|
27
|
+
| `input_tokens` | Integer | Prompt tokens |
|
|
28
|
+
| `output_tokens` | Integer | Completion tokens |
|
|
29
|
+
| `total_cost_cents` | Integer | Calculated cost in cents |
|
|
30
|
+
| `latency_ms` | Integer | Wall-clock time of the LLM call |
|
|
31
|
+
| `created_at` | Time | When the call was recorded |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Examples
|
|
36
|
+
|
|
37
|
+
### StatsD / Datadog
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
TokenHawk.subscribe(:call_recorded) do |call|
|
|
41
|
+
tags = ["domain:#{call.domain}", "vendor:#{call.vendor}", "model:#{call.model}"]
|
|
42
|
+
StatsD.increment("llm.calls", tags: tags)
|
|
43
|
+
StatsD.gauge("llm.cost_cents", call.total_cost_cents, tags: tags)
|
|
44
|
+
StatsD.timing("llm.latency_ms", call.latency_ms, tags: tags)
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Budget alerting
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
TokenHawk.subscribe(:call_recorded) do |call|
|
|
52
|
+
monthly = TokenHawk::CallRecord
|
|
53
|
+
.since(Time.now.beginning_of_month)
|
|
54
|
+
.for_domain(call.domain)
|
|
55
|
+
.sum(:total_cost_cents)
|
|
56
|
+
|
|
57
|
+
if monthly > 10_000 # $100
|
|
58
|
+
SlackNotifier.alert("#{call.domain} has exceeded $100 this month")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Secondary logging
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
TokenHawk.subscribe(:call_recorded) do |call|
|
|
67
|
+
Rails.logger.info({
|
|
68
|
+
event: "llm_call",
|
|
69
|
+
domain: call.domain,
|
|
70
|
+
model: call.model,
|
|
71
|
+
total_cost_cents: call.total_cost_cents,
|
|
72
|
+
latency_ms: call.latency_ms
|
|
73
|
+
}.to_json)
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Error handling
|
|
80
|
+
|
|
81
|
+
Exceptions raised inside a subscriber are rescued and do not propagate to the caller. If `config.log_failures` is true, the error message is passed to `config.failure_logger`.
|
data/exe/token_hawk
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "token_hawk"
|
|
5
|
+
require "token_hawk/cli"
|
|
6
|
+
|
|
7
|
+
rails_env = File.expand_path("config/environment.rb", Dir.pwd)
|
|
8
|
+
if File.exist?(rails_env)
|
|
9
|
+
require rails_env
|
|
10
|
+
elsif ENV["DATABASE_URL"]
|
|
11
|
+
require "active_record"
|
|
12
|
+
ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
TokenHawk::CLI.start(ARGV)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "active_record"
|
|
6
|
+
|
|
7
|
+
module TokenHawk
|
|
8
|
+
module Generators
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
desc "Install TokenHawk: copies migrations, creates initializer, mounts engine."
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(path)
|
|
16
|
+
next_migration_number = current_migration_number(path) + 1
|
|
17
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_migrations
|
|
21
|
+
migration_template "create_token_hawk_calls.rb", "db/migrate/create_token_hawk_calls.rb"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_initializer
|
|
25
|
+
template "token_hawk.rb", "config/initializers/token_hawk.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mount_engine
|
|
29
|
+
route 'mount TokenHawk::Engine => "/token_hawk"'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show_readme
|
|
33
|
+
say "\n✅ TokenHawk installed. Next steps:", :green
|
|
34
|
+
say " 1. bin/rails db:migrate"
|
|
35
|
+
say " 2. Attribute LLM calls — two interfaces:"
|
|
36
|
+
say " TokenHawk.track(domain: :my_feature) { client.messages.create(...) }"
|
|
37
|
+
say " TokenHawk.record(domain: :my_feature, model: \"...\", input_tokens: N, output_tokens: N, latency_ms: N)"
|
|
38
|
+
say " 3. Visit /token_hawk for the dashboard\n"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateTokenHawkCalls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :token_hawk_calls do |t|
|
|
4
|
+
t.string :domain, null: false
|
|
5
|
+
t.text :tags, null: false, default: "{}"
|
|
6
|
+
t.string :vendor, null: false
|
|
7
|
+
t.string :model, null: false
|
|
8
|
+
t.integer :input_tokens, null: false
|
|
9
|
+
t.integer :output_tokens, null: false
|
|
10
|
+
t.integer :total_cost_cents, null: false
|
|
11
|
+
t.integer :latency_ms, null: false
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :token_hawk_calls, :domain
|
|
16
|
+
add_index :token_hawk_calls, :created_at
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
TokenHawk.configure do |config|
|
|
4
|
+
# Storage backend: :active_record (default) or :memory
|
|
5
|
+
# config.storage = :active_record
|
|
6
|
+
|
|
7
|
+
# Log telemetry failures — set to false to silence them entirely
|
|
8
|
+
# config.log_failures = true
|
|
9
|
+
|
|
10
|
+
# Custom failure logger — must respond to #call
|
|
11
|
+
# config.failure_logger = ->(msg) { Rails.logger.warn(msg) }
|
|
12
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenHawk
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
def vendor_name
|
|
7
|
+
raise NotImplementedError, "#{self.class} must implement vendor_name"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def extract_model(response)
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement extract_model"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract_input_tokens(response)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement extract_input_tokens"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def extract_output_tokens(response)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement extract_output_tokens"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module TokenHawk
|
|
6
|
+
class CallRecord < ActiveRecord::Base
|
|
7
|
+
self.table_name = "token_hawk_calls"
|
|
8
|
+
|
|
9
|
+
serialize :tags, coder: JSON
|
|
10
|
+
|
|
11
|
+
scope :for_domain, ->(name) { where(domain: name) }
|
|
12
|
+
scope :since, ->(time) { where("created_at >= ?", time) }
|
|
13
|
+
|
|
14
|
+
def self.with_tag(key, value)
|
|
15
|
+
all.select { |r| r.tags[key.to_s] == value }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../formatter"
|
|
5
|
+
|
|
6
|
+
module TokenHawk
|
|
7
|
+
class CLI
|
|
8
|
+
module Commands
|
|
9
|
+
class Costs
|
|
10
|
+
include Formatter
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
results = grouped_results(scoped_records)
|
|
17
|
+
@options["format"] == "json" ? print_json(results) : print_table(results)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def scoped_records
|
|
23
|
+
scope = CallRecord.all
|
|
24
|
+
scope = scope.for_domain(@options["domain"]) if @options["domain"]
|
|
25
|
+
scope = scope.since(Time.parse(@options["since"])) if @options["since"]
|
|
26
|
+
scope
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def grouped_results(scope)
|
|
30
|
+
return ruby_grouped_results(scope.to_a) if ruby_path?
|
|
31
|
+
|
|
32
|
+
sql_grouped_results(scope)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ruby_path?
|
|
36
|
+
@options["by"] == "tag" || tag_filter?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def tag_filter?
|
|
40
|
+
@options["tag"] && !@options["tag"].empty? && @options["by"] != "tag"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ruby_grouped_results(records)
|
|
44
|
+
records = filter_by_tag(records) if tag_filter?
|
|
45
|
+
return [] if records.empty?
|
|
46
|
+
|
|
47
|
+
grouped = if @options["by"] == "tag"
|
|
48
|
+
records.group_by { |r| r.tags[@options["tag"].to_s].to_s }
|
|
49
|
+
else
|
|
50
|
+
records.group_by(&:domain)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
grouped
|
|
54
|
+
.map { |key, recs| { "group" => key.to_s, "total_cost_cents" => recs.sum(&:total_cost_cents), "call_count" => recs.size } }
|
|
55
|
+
.sort_by { |r| -r["total_cost_cents"] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def filter_by_tag(records)
|
|
59
|
+
key, value = @options["tag"].split("=", 2)
|
|
60
|
+
records.select { |r| r.tags[key.to_s] == value }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sql_grouped_results(scope)
|
|
64
|
+
grouped = scope.group(group_expression)
|
|
65
|
+
costs = grouped.sum(:total_cost_cents)
|
|
66
|
+
counts = grouped.count
|
|
67
|
+
|
|
68
|
+
costs
|
|
69
|
+
.map { |key, total| { "group" => key.to_s, "total_cost_cents" => total, "call_count" => counts[key] || 0 } }
|
|
70
|
+
.sort_by { |r| -r["total_cost_cents"] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def group_expression
|
|
74
|
+
@options["by"] == "day" ? Arel.sql("DATE(created_at)") : :domain
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def print_table(results)
|
|
78
|
+
return puts("No records found.") if results.empty?
|
|
79
|
+
|
|
80
|
+
width = results.map { |r| r["group"].length }.max + 2
|
|
81
|
+
results.each do |row|
|
|
82
|
+
puts format("%-#{width}s %8s %4d calls", row["group"], format_cost(row["total_cost_cents"]), row["call_count"])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def print_json(results)
|
|
87
|
+
puts JSON.generate(results)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../formatter"
|
|
5
|
+
|
|
6
|
+
module TokenHawk
|
|
7
|
+
class CLI
|
|
8
|
+
module Commands
|
|
9
|
+
class Efficiency
|
|
10
|
+
include Formatter
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
results = ranked_results
|
|
17
|
+
@options["format"] == "json" ? print_json(results) : print_table(results)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def ranked_results
|
|
23
|
+
costs = CallRecord.group(:domain).sum(:total_cost_cents)
|
|
24
|
+
counts = CallRecord.group(:domain).count
|
|
25
|
+
|
|
26
|
+
costs
|
|
27
|
+
.map do |domain, total|
|
|
28
|
+
count = counts[domain] || 1
|
|
29
|
+
{
|
|
30
|
+
"domain" => domain.to_s,
|
|
31
|
+
"cost_per_call_cents" => (total.to_f / count).round(2),
|
|
32
|
+
"call_count" => count
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
.sort_by { |r| -r["cost_per_call_cents"] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_table(results)
|
|
39
|
+
return puts("No records found.") if results.empty?
|
|
40
|
+
|
|
41
|
+
width = results.map { |r| r["domain"].length }.max + 2
|
|
42
|
+
results.each do |row|
|
|
43
|
+
puts format("%-#{width}s %8s/call %4d calls",
|
|
44
|
+
row["domain"], format_cost(row["cost_per_call_cents"].ceil), row["call_count"])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def print_json(results)
|
|
49
|
+
puts JSON.generate(results)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../formatter"
|
|
5
|
+
|
|
6
|
+
module TokenHawk
|
|
7
|
+
class CLI
|
|
8
|
+
module Commands
|
|
9
|
+
class Recent
|
|
10
|
+
include Formatter
|
|
11
|
+
def initialize(options = {})
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
records = CallRecord.order(created_at: :desc).limit(limit)
|
|
17
|
+
@options["format"] == "json" ? print_json(records) : print_table(records)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def limit
|
|
23
|
+
@options.fetch("limit", 20).to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print_table(records)
|
|
27
|
+
return puts("No records found.") if records.empty?
|
|
28
|
+
|
|
29
|
+
records.each do |r|
|
|
30
|
+
puts format("%s %-24s %-30s %s",
|
|
31
|
+
r.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
32
|
+
r.domain,
|
|
33
|
+
r.model,
|
|
34
|
+
format_cost(r.total_cost_cents))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print_json(records)
|
|
39
|
+
rows = records.map do |r|
|
|
40
|
+
{
|
|
41
|
+
"domain" => r.domain,
|
|
42
|
+
"model" => r.model,
|
|
43
|
+
"total_cost_cents" => r.total_cost_cents,
|
|
44
|
+
"created_at" => r.created_at.iso8601
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
puts JSON.generate(rows)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module TokenHawk
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc "costs", "Show LLM costs grouped by domain, day, or tag"
|
|
8
|
+
method_option :by, type: :string, default: "domain", desc: "Group by: domain, day, or tag"
|
|
9
|
+
method_option :domain, type: :string, desc: "Filter to a specific domain"
|
|
10
|
+
method_option :since, type: :string, desc: "Filter to records since a time (e.g. '30 days ago')"
|
|
11
|
+
method_option :tag, type: :string, desc: "Filter by tag key=value"
|
|
12
|
+
method_option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
13
|
+
def costs
|
|
14
|
+
Commands::Costs.new(options).run
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "efficiency", "Show cost per call by domain, descending"
|
|
18
|
+
method_option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
19
|
+
def efficiency
|
|
20
|
+
Commands::Efficiency.new(options).run
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc "recent", "Show recent LLM calls"
|
|
24
|
+
method_option :limit, type: :numeric, default: 20, desc: "Number of records to show"
|
|
25
|
+
method_option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
26
|
+
def recent
|
|
27
|
+
Commands::Recent.new(options).run
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
require_relative "cli/commands/costs"
|
|
33
|
+
require_relative "cli/commands/efficiency"
|
|
34
|
+
require_relative "cli/commands/recent"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenHawk
|
|
4
|
+
# Configuration options for TokenHawk
|
|
5
|
+
#
|
|
6
|
+
# @attr storage [Symbol] Storage backend (:active_record, :memory)
|
|
7
|
+
# @attr log_failures [Boolean] Whether to log failures
|
|
8
|
+
# @attr failure_logger [Proc] Callable that receives error messages
|
|
9
|
+
class Configuration
|
|
10
|
+
attr_accessor :storage, :log_failures, :failure_logger, :pricing
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@storage = :active_record
|
|
14
|
+
@log_failures = true
|
|
15
|
+
@failure_logger = ->(msg) { warn msg }
|
|
16
|
+
@pricing = {}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenHawk
|
|
4
|
+
module Cost
|
|
5
|
+
def self.calculate(model:, input_tokens:, output_tokens:)
|
|
6
|
+
rates = Pricing.for_model(model)
|
|
7
|
+
|
|
8
|
+
unless rates
|
|
9
|
+
warn "[TokenHawk] Looks like you may need to add an entry for #{model.inspect} — " \
|
|
10
|
+
"cost recorded as 0 and vendor set to \"unknown\". " \
|
|
11
|
+
"In your initializer: config.pricing[#{model.inspect}] = { vendor: \"anthropic\", input: 0.0, output: 0.0 }"
|
|
12
|
+
return 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
((input_tokens * rates[:input] + output_tokens * rates[:output]) / 1000.0).round
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenHawk
|
|
4
|
+
class DashboardQuery
|
|
5
|
+
def initialize(since:)
|
|
6
|
+
@since = since
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def monthly_total
|
|
10
|
+
scope.sum(:total_cost_cents)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def top_domains
|
|
14
|
+
scope.group(:domain)
|
|
15
|
+
.sum(:total_cost_cents)
|
|
16
|
+
.sort_by { |_, cost| -cost }
|
|
17
|
+
.first(3)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def daily_costs
|
|
21
|
+
scope.group(Arel.sql("DATE(created_at)"))
|
|
22
|
+
.sum(:total_cost_cents)
|
|
23
|
+
.sort_by { |date, _| date }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def scope
|
|
29
|
+
CallRecord.since(@since)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenHawk
|
|
4
|
+
class DomainQuery
|
|
5
|
+
def initialize(domain:)
|
|
6
|
+
@domain = domain.to_s
|
|
7
|
+
@scope = CallRecord.for_domain(@domain)
|
|
8
|
+
raise ActiveRecord::RecordNotFound, "No records for domain: #{@domain}" if @scope.empty?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def total_cost
|
|
12
|
+
@scope.sum(:total_cost_cents)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call_count
|
|
16
|
+
@scope.count
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cost_per_call
|
|
20
|
+
(total_cost.to_f / call_count).round(2)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def top_tags
|
|
24
|
+
@scope.to_a
|
|
25
|
+
.flat_map { |r| r.tags.to_a }
|
|
26
|
+
.tally
|
|
27
|
+
.sort_by { |_, count| -count }
|
|
28
|
+
.map(&:first)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def recent_calls
|
|
32
|
+
@scope.order(created_at: :desc).limit(10)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|