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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +51 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +201 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/stylesheets/token_hawk/application.css +106 -0
  8. data/app/controllers/token_hawk/application_controller.rb +6 -0
  9. data/app/controllers/token_hawk/dashboard_controller.rb +12 -0
  10. data/app/controllers/token_hawk/domains_controller.rb +15 -0
  11. data/app/controllers/token_hawk/pricing_controller.rb +9 -0
  12. data/app/controllers/token_hawk/recent_controller.rb +12 -0
  13. data/app/views/layouts/token_hawk/application.html.erb +251 -0
  14. data/app/views/token_hawk/dashboard/index.html.erb +53 -0
  15. data/app/views/token_hawk/domains/show.html.erb +48 -0
  16. data/app/views/token_hawk/pricing/index.html.erb +27 -0
  17. data/app/views/token_hawk/recent/index.html.erb +36 -0
  18. data/config/routes.rb +8 -0
  19. data/db/migrate/01_create_token_hawk_calls.rb +22 -0
  20. data/db/migrate/02_create_token_hawk_llm_rates.rb +16 -0
  21. data/docs/cli.md +98 -0
  22. data/docs/configuration.md +90 -0
  23. data/docs/dashboard.md +73 -0
  24. data/docs/subscribe_hooks.md +81 -0
  25. data/exe/token_hawk +15 -0
  26. data/lib/generators/token_hawk/install/install_generator.rb +42 -0
  27. data/lib/generators/token_hawk/install/templates/create_token_hawk_calls.rb +18 -0
  28. data/lib/generators/token_hawk/install/templates/token_hawk.rb +12 -0
  29. data/lib/token_hawk/adapters/base.rb +23 -0
  30. data/lib/token_hawk/call.rb +15 -0
  31. data/lib/token_hawk/call_record.rb +18 -0
  32. data/lib/token_hawk/cli/commands/costs.rb +92 -0
  33. data/lib/token_hawk/cli/commands/efficiency.rb +54 -0
  34. data/lib/token_hawk/cli/commands/recent.rb +52 -0
  35. data/lib/token_hawk/cli/formatter.rb +13 -0
  36. data/lib/token_hawk/cli.rb +34 -0
  37. data/lib/token_hawk/configuration.rb +19 -0
  38. data/lib/token_hawk/cost.rb +18 -0
  39. data/lib/token_hawk/dashboard_query.rb +32 -0
  40. data/lib/token_hawk/domain_query.rb +35 -0
  41. data/lib/token_hawk/engine.rb +7 -0
  42. data/lib/token_hawk/pricing.rb +41 -0
  43. data/lib/token_hawk/recent_query.rb +16 -0
  44. data/lib/token_hawk/response_adapter.rb +23 -0
  45. data/lib/token_hawk/storage/active_record.rb +54 -0
  46. data/lib/token_hawk/storage/memory.rb +33 -0
  47. data/lib/token_hawk/version.rb +5 -0
  48. data/lib/token_hawk.rb +122 -0
  49. data/sig/token_hawk.rbs +4 -0
  50. metadata +172 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ module Pricing
5
+ RATES = {
6
+ # Anthropic — Claude 4 family
7
+ "claude-opus-4-7" => { vendor: "anthropic", input: 1.5, output: 7.5 },
8
+ "claude-opus-4-8" => { vendor: "anthropic", input: 1.5, output: 7.5 },
9
+ "claude-sonnet-4-6" => { vendor: "anthropic", input: 0.3, output: 1.5 },
10
+ "claude-haiku-4-5" => { vendor: "anthropic", input: 0.025, output: 0.125 },
11
+ "claude-haiku-4-5-20251001" => { vendor: "anthropic", input: 0.025, output: 0.125 },
12
+ # OpenAI — GPT-4o and o1 families
13
+ "gpt-4o" => { vendor: "openai", input: 0.25, output: 1.0 },
14
+ "gpt-4o-mini" => { vendor: "openai", input: 0.015, output: 0.06 },
15
+ "o1" => { vendor: "openai", input: 1.5, output: 6.0 },
16
+ "o1-mini" => { vendor: "openai", input: 0.3, output: 1.2 }
17
+ }.freeze
18
+
19
+ VENDOR_PREFIXES = {
20
+ "claude" => "anthropic",
21
+ "gpt" => "openai",
22
+ "o1" => "openai",
23
+ "o3" => "openai"
24
+ }.freeze
25
+
26
+ def self.for_model(model)
27
+ entry = TokenHawk.configuration.pricing[model.to_s] || RATES[model.to_s]
28
+ return nil unless entry
29
+
30
+ { input: entry[:input].to_f, output: entry[:output].to_f }
31
+ end
32
+
33
+ def self.vendor_for_model(model)
34
+ entry = TokenHawk.configuration.pricing[model.to_s] || RATES[model.to_s]
35
+ return entry[:vendor] if entry
36
+
37
+ prefix = VENDOR_PREFIXES.keys.find { |p| model.to_s.start_with?(p) }
38
+ prefix ? VENDOR_PREFIXES[prefix] : "unknown"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class RecentQuery
5
+ def initialize(domain: nil, limit: 50)
6
+ @domain = domain
7
+ @limit = limit
8
+ end
9
+
10
+ def calls
11
+ scope = CallRecord.order(created_at: :desc).limit(@limit)
12
+ scope = scope.for_domain(@domain) if @domain
13
+ scope
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ class ResponseAdapter
5
+ def extract_model(response)
6
+ response.model if response.respond_to?(:model)
7
+ end
8
+
9
+ def extract_input_tokens(response)
10
+ return nil unless response.respond_to?(:usage) && response.usage
11
+ response.usage.input_tokens
12
+ end
13
+
14
+ def extract_output_tokens(response)
15
+ return nil unless response.respond_to?(:usage) && response.usage
16
+ response.usage.output_tokens
17
+ end
18
+
19
+ def vendor_name(response)
20
+ Pricing.vendor_for_model(extract_model(response).to_s)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ module Storage
5
+ class ActiveRecord
6
+ class << self
7
+ def record(call)
8
+ CallRecord.create!(
9
+ domain: call.domain.to_s,
10
+ tags: call.tags,
11
+ vendor: call.vendor,
12
+ model: call.model,
13
+ input_tokens: call.input_tokens,
14
+ output_tokens: call.output_tokens,
15
+ total_cost_cents: call.total_cost_cents,
16
+ latency_ms: call.latency_ms,
17
+ created_at: call.created_at
18
+ )
19
+ end
20
+
21
+ def all
22
+ CallRecord.all.map { |r| to_call(r) }
23
+ end
24
+
25
+ def where(domain: nil, since: nil)
26
+ scope = CallRecord.all
27
+ scope = scope.for_domain(domain.to_s) if domain
28
+ scope = scope.since(since) if since
29
+ scope.map { |r| to_call(r) }
30
+ end
31
+
32
+ def clear
33
+ CallRecord.delete_all
34
+ end
35
+
36
+ private
37
+
38
+ def to_call(record)
39
+ Call.new(
40
+ domain: record.domain.to_sym,
41
+ tags: record.tags,
42
+ vendor: record.vendor,
43
+ model: record.model,
44
+ input_tokens: record.input_tokens,
45
+ output_tokens: record.output_tokens,
46
+ total_cost_cents: record.total_cost_cents,
47
+ latency_ms: record.latency_ms,
48
+ created_at: record.created_at
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ module Storage
5
+ class Memory
6
+ @calls = []
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ def record(call)
11
+ @mutex.synchronize { @calls << call }
12
+ end
13
+
14
+ def all
15
+ @mutex.synchronize { @calls.dup }
16
+ end
17
+
18
+ def where(domain: nil, since: nil)
19
+ @mutex.synchronize do
20
+ @calls.select do |call|
21
+ (domain.nil? || call.domain == domain) &&
22
+ (since.nil? || call.created_at >= since)
23
+ end
24
+ end
25
+ end
26
+
27
+ def clear
28
+ @mutex.synchronize { @calls.clear }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenHawk
4
+ VERSION = "0.1.0"
5
+ end
data/lib/token_hawk.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "token_hawk/version"
4
+ require_relative "token_hawk/configuration"
5
+ require_relative "token_hawk/engine" if defined?(Rails)
6
+ require_relative "token_hawk/call"
7
+ require_relative "token_hawk/pricing"
8
+ require_relative "token_hawk/cost"
9
+ require_relative "token_hawk/adapters/base"
10
+ require_relative "token_hawk/response_adapter"
11
+ require_relative "token_hawk/storage/memory"
12
+ require_relative "token_hawk/storage/active_record"
13
+ require_relative "token_hawk/call_record"
14
+ require_relative "token_hawk/dashboard_query"
15
+ require_relative "token_hawk/domain_query"
16
+ require_relative "token_hawk/recent_query"
17
+
18
+ module TokenHawk
19
+ class Error < StandardError; end
20
+
21
+ @subscribers = Hash.new { |h, k| h[k] = [] }
22
+ @configuration = Configuration.new
23
+
24
+ def self.configuration
25
+ @configuration
26
+ end
27
+
28
+ def self.configure
29
+ yield @configuration
30
+ end
31
+
32
+ def self.reset_configuration!
33
+ @configuration = Configuration.new
34
+ end
35
+
36
+ def self.subscribe(event, &block)
37
+ @subscribers[event] << block
38
+ end
39
+
40
+ def self.clear_subscribers
41
+ @subscribers.clear
42
+ end
43
+
44
+ def self.track(domain:, tags: {})
45
+ start_time = Time.now
46
+ response = yield
47
+ elapsed_ms = ((Time.now - start_time) * 1000).round
48
+
49
+ begin
50
+ record_call(domain: domain, tags: tags, response: response, elapsed_ms: elapsed_ms)
51
+ rescue => e
52
+ configuration.failure_logger.call("[TokenHawk] Failed to record telemetry: #{e.message}") if configuration.log_failures
53
+ end
54
+
55
+ response
56
+ end
57
+
58
+ def self.record(domain:, model:, input_tokens:, output_tokens:, latency_ms:, tags: {})
59
+ call = Call.new(
60
+ domain: domain.to_sym,
61
+ tags: tags,
62
+ vendor: Pricing.vendor_for_model(model.to_s),
63
+ model: model.to_s,
64
+ input_tokens: input_tokens.to_i,
65
+ output_tokens: output_tokens.to_i,
66
+ total_cost_cents: Cost.calculate(model: model.to_s, input_tokens: input_tokens.to_i, output_tokens: output_tokens.to_i),
67
+ latency_ms: latency_ms.to_i,
68
+ created_at: Time.now
69
+ )
70
+
71
+ storage_backend.record(call)
72
+
73
+ @subscribers[:call_recorded].each do |subscriber|
74
+ subscriber.call(call)
75
+ rescue => e
76
+ configuration.failure_logger.call("[TokenHawk] Subscriber error: #{e.message}") if configuration.log_failures
77
+ end
78
+ rescue => e
79
+ configuration.failure_logger.call("[TokenHawk] Failed to record telemetry: #{e.message}") if configuration.log_failures
80
+ end
81
+
82
+ def self.record_call(domain:, tags:, response:, elapsed_ms:)
83
+ adapter = ResponseAdapter.new
84
+ model = adapter.extract_model(response)
85
+ input_tokens = adapter.extract_input_tokens(response)
86
+ output_tokens = adapter.extract_output_tokens(response)
87
+
88
+ if model.nil? && input_tokens.nil?
89
+ configuration.failure_logger.call("[TokenHawk] Could not extract telemetry from response — skipping record.") if configuration.log_failures
90
+ return
91
+ end
92
+
93
+ call = Call.new(
94
+ domain: domain,
95
+ tags: tags,
96
+ vendor: adapter.vendor_name(response),
97
+ model: model.to_s,
98
+ input_tokens: input_tokens.to_i,
99
+ output_tokens: output_tokens.to_i,
100
+ total_cost_cents: Cost.calculate(model: model.to_s, input_tokens: input_tokens.to_i, output_tokens: output_tokens.to_i),
101
+ latency_ms: elapsed_ms,
102
+ created_at: Time.now
103
+ )
104
+
105
+ storage_backend.record(call)
106
+
107
+ @subscribers[:call_recorded].each do |subscriber|
108
+ subscriber.call(call)
109
+ rescue => e
110
+ configuration.failure_logger.call("[TokenHawk] Subscriber error: #{e.message}") if configuration.log_failures
111
+ end
112
+ end
113
+ def self.storage_backend
114
+ case configuration.storage
115
+ when :memory then Storage::Memory
116
+ when :active_record then Storage::ActiveRecord
117
+ else raise Error, "Unknown storage backend: #{configuration.storage.inspect}"
118
+ end
119
+ end
120
+
121
+ private_class_method :record_call, :storage_backend
122
+ end
@@ -0,0 +1,4 @@
1
+ module TokenHawk
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,172 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: token_hawk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rockwell Windsor Rice
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '7.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9'
53
+ - !ruby/object:Gem::Dependency
54
+ name: railties
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '7.0'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '9'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '7.0'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '9'
73
+ - !ruby/object:Gem::Dependency
74
+ name: thor
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '1.0'
80
+ type: :runtime
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '1.0'
87
+ description: Wrap LLM API calls with a single block to capture token counts, latency,
88
+ and cost per call — attributed by domain and tagged with arbitrary metadata. Persists
89
+ to your own Postgres database. No SaaS required.
90
+ email:
91
+ - rockwellwindsor@gmail.com
92
+ executables:
93
+ - token_hawk
94
+ extensions: []
95
+ extra_rdoc_files: []
96
+ files:
97
+ - CHANGELOG.md
98
+ - CODE_OF_CONDUCT.md
99
+ - LICENSE.txt
100
+ - README.md
101
+ - Rakefile
102
+ - app/assets/stylesheets/token_hawk/application.css
103
+ - app/controllers/token_hawk/application_controller.rb
104
+ - app/controllers/token_hawk/dashboard_controller.rb
105
+ - app/controllers/token_hawk/domains_controller.rb
106
+ - app/controllers/token_hawk/pricing_controller.rb
107
+ - app/controllers/token_hawk/recent_controller.rb
108
+ - app/views/layouts/token_hawk/application.html.erb
109
+ - app/views/token_hawk/dashboard/index.html.erb
110
+ - app/views/token_hawk/domains/show.html.erb
111
+ - app/views/token_hawk/pricing/index.html.erb
112
+ - app/views/token_hawk/recent/index.html.erb
113
+ - config/routes.rb
114
+ - db/migrate/01_create_token_hawk_calls.rb
115
+ - db/migrate/02_create_token_hawk_llm_rates.rb
116
+ - docs/cli.md
117
+ - docs/configuration.md
118
+ - docs/dashboard.md
119
+ - docs/subscribe_hooks.md
120
+ - exe/token_hawk
121
+ - lib/generators/token_hawk/install/install_generator.rb
122
+ - lib/generators/token_hawk/install/templates/create_token_hawk_calls.rb
123
+ - lib/generators/token_hawk/install/templates/token_hawk.rb
124
+ - lib/token_hawk.rb
125
+ - lib/token_hawk/adapters/base.rb
126
+ - lib/token_hawk/call.rb
127
+ - lib/token_hawk/call_record.rb
128
+ - lib/token_hawk/cli.rb
129
+ - lib/token_hawk/cli/commands/costs.rb
130
+ - lib/token_hawk/cli/commands/efficiency.rb
131
+ - lib/token_hawk/cli/commands/recent.rb
132
+ - lib/token_hawk/cli/formatter.rb
133
+ - lib/token_hawk/configuration.rb
134
+ - lib/token_hawk/cost.rb
135
+ - lib/token_hawk/dashboard_query.rb
136
+ - lib/token_hawk/domain_query.rb
137
+ - lib/token_hawk/engine.rb
138
+ - lib/token_hawk/pricing.rb
139
+ - lib/token_hawk/recent_query.rb
140
+ - lib/token_hawk/response_adapter.rb
141
+ - lib/token_hawk/storage/active_record.rb
142
+ - lib/token_hawk/storage/memory.rb
143
+ - lib/token_hawk/version.rb
144
+ - sig/token_hawk.rbs
145
+ homepage: https://github.com/rockwellwindsor/token_hawk
146
+ licenses:
147
+ - MIT
148
+ metadata:
149
+ allowed_push_host: https://rubygems.org
150
+ homepage_uri: https://github.com/rockwellwindsor/token_hawk
151
+ source_code_uri: https://github.com/rockwellwindsor/token_hawk
152
+ changelog_uri: https://github.com/rockwellwindsor/token_hawk/blob/main/CHANGELOG.md
153
+ post_install_message:
154
+ rdoc_options: []
155
+ require_paths:
156
+ - lib
157
+ required_ruby_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: 3.2.0
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ requirements: []
168
+ rubygems_version: 3.4.1
169
+ signing_key:
170
+ specification_version: 4
171
+ summary: Code-native LLM cost attribution for Rails applications.
172
+ test_files: []