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,251 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>TokenHawk</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
12
+ background: #f1f5f9;
13
+ color: #1e293b;
14
+ font-size: 14px;
15
+ line-height: 1.5;
16
+ }
17
+
18
+ a { color: inherit; text-decoration: none; }
19
+
20
+ /* ── Nav ──────────────────────────────────────────────── */
21
+
22
+ .th-nav {
23
+ background: #0f172a;
24
+ border-bottom: 1px solid #1e293b;
25
+ position: sticky;
26
+ top: 0;
27
+ z-index: 10;
28
+ }
29
+
30
+ .th-nav-inner {
31
+ max-width: 1100px;
32
+ margin: 0 auto;
33
+ padding: 0 1.5rem;
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 2rem;
37
+ height: 52px;
38
+ }
39
+
40
+ .th-brand {
41
+ font-size: 13px;
42
+ font-weight: 700;
43
+ color: #f8fafc;
44
+ letter-spacing: 0.08em;
45
+ text-transform: uppercase;
46
+ white-space: nowrap;
47
+ }
48
+
49
+ .th-brand span {
50
+ color: #38bdf8;
51
+ }
52
+
53
+ .th-nav-links {
54
+ display: flex;
55
+ align-items: stretch;
56
+ gap: 0;
57
+ height: 100%;
58
+ }
59
+
60
+ .th-nav-link {
61
+ display: flex;
62
+ align-items: center;
63
+ padding: 0 1rem;
64
+ font-size: 13px;
65
+ color: #94a3b8;
66
+ height: 100%;
67
+ border-bottom: 2px solid transparent;
68
+ transition: color 0.15s, border-color 0.15s;
69
+ }
70
+
71
+ .th-nav-link:hover { color: #e2e8f0; }
72
+
73
+ .th-nav-link--active {
74
+ color: #f8fafc;
75
+ border-bottom-color: #38bdf8;
76
+ }
77
+
78
+ /* ── Page shell ───────────────────────────────────────── */
79
+
80
+ .th-page {
81
+ max-width: 1100px;
82
+ margin: 0 auto;
83
+ padding: 2rem 1.5rem 4rem;
84
+ }
85
+
86
+ .th-page-header {
87
+ margin-bottom: 1.75rem;
88
+ }
89
+
90
+ .th-page-title {
91
+ font-size: 1.375rem;
92
+ font-weight: 700;
93
+ color: #0f172a;
94
+ }
95
+
96
+ .th-page-subtitle {
97
+ font-size: 13px;
98
+ color: #64748b;
99
+ margin-top: 0.25rem;
100
+ }
101
+
102
+ /* ── Stat cards ───────────────────────────────────────── */
103
+
104
+ .th-stats {
105
+ display: flex;
106
+ gap: 1rem;
107
+ flex-wrap: wrap;
108
+ margin-bottom: 2rem;
109
+ }
110
+
111
+ .th-card {
112
+ background: #fff;
113
+ border: 1px solid #e2e8f0;
114
+ border-radius: 8px;
115
+ padding: 1.25rem 1.5rem;
116
+ min-width: 160px;
117
+ }
118
+
119
+ .th-card-label {
120
+ font-size: 11px;
121
+ font-weight: 600;
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.08em;
124
+ color: #94a3b8;
125
+ margin-bottom: 0.5rem;
126
+ }
127
+
128
+ .th-card-value {
129
+ font-size: 1.75rem;
130
+ font-weight: 700;
131
+ color: #0f172a;
132
+ line-height: 1;
133
+ }
134
+
135
+ /* ── Section ──────────────────────────────────────────── */
136
+
137
+ .th-section {
138
+ background: #fff;
139
+ border: 1px solid #e2e8f0;
140
+ border-radius: 8px;
141
+ margin-bottom: 1.5rem;
142
+ }
143
+
144
+ .th-section-header {
145
+ padding: 0.875rem 1.25rem;
146
+ border-bottom: 1px solid #f1f5f9;
147
+ font-size: 12px;
148
+ font-weight: 600;
149
+ text-transform: uppercase;
150
+ letter-spacing: 0.07em;
151
+ color: #64748b;
152
+ }
153
+
154
+ /* ── Tables ───────────────────────────────────────────── */
155
+
156
+ .th-table {
157
+ width: 100%;
158
+ border-collapse: collapse;
159
+ }
160
+
161
+ .th-table th {
162
+ text-align: left;
163
+ padding: 0.625rem 1.25rem;
164
+ font-size: 11px;
165
+ font-weight: 600;
166
+ text-transform: uppercase;
167
+ letter-spacing: 0.07em;
168
+ color: #94a3b8;
169
+ background: #f8fafc;
170
+ border-bottom: 1px solid #e2e8f0;
171
+ }
172
+
173
+ .th-table th:not(:first-child) { text-align: right; }
174
+
175
+ .th-table td {
176
+ padding: 0.625rem 1.25rem;
177
+ border-bottom: 1px solid #f1f5f9;
178
+ color: #334155;
179
+ }
180
+
181
+ .th-table td:not(:first-child) { text-align: right; }
182
+
183
+ .th-table tr:last-child td { border-bottom: none; }
184
+
185
+ .th-table tbody tr:hover td { background: #f8fafc; }
186
+
187
+ .th-table td.th-mono {
188
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
189
+ font-size: 12px;
190
+ color: #1e293b;
191
+ }
192
+
193
+ .th-table td.th-dim { color: #94a3b8; }
194
+
195
+ /* ── Tags ─────────────────────────────────────────────── */
196
+
197
+ .th-tag-list {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 0.375rem;
201
+ padding: 1rem 1.25rem;
202
+ }
203
+
204
+ .th-tag {
205
+ background: #eff6ff;
206
+ color: #2563eb;
207
+ border: 1px solid #bfdbfe;
208
+ border-radius: 4px;
209
+ padding: 0.2rem 0.5rem;
210
+ font-size: 12px;
211
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
212
+ }
213
+
214
+ /* ── Empty state ──────────────────────────────────────── */
215
+
216
+ .th-empty {
217
+ padding: 3rem 1.25rem;
218
+ text-align: center;
219
+ color: #94a3b8;
220
+ font-size: 13px;
221
+ }
222
+
223
+ /* ── Note ─────────────────────────────────────────────── */
224
+
225
+ .th-note {
226
+ font-size: 12px;
227
+ color: #94a3b8;
228
+ padding: 0.75rem 1.25rem;
229
+ border-bottom: 1px solid #f1f5f9;
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+
235
+ <nav class="th-nav">
236
+ <div class="th-nav-inner">
237
+ <span class="th-brand">Token<span>Hawk</span></span>
238
+ <div class="th-nav-links">
239
+ <%= link_to "Overview", root_path, class: "th-nav-link #{controller_name == 'dashboard' ? 'th-nav-link--active' : ''}" %>
240
+ <%= link_to "Recent", recent_path, class: "th-nav-link #{controller_name == 'recent' ? 'th-nav-link--active' : ''}" %>
241
+ <%= link_to "Pricing", pricing_path, class: "th-nav-link #{controller_name == 'pricing' ? 'th-nav-link--active' : ''}" %>
242
+ </div>
243
+ </div>
244
+ </nav>
245
+
246
+ <div class="th-page">
247
+ <%= yield %>
248
+ </div>
249
+
250
+ </body>
251
+ </html>
@@ -0,0 +1,53 @@
1
+ <div class="th-page-header">
2
+ <h1 class="th-page-title">Overview</h1>
3
+ <p class="th-page-subtitle">LLM cost attribution for <%= Time.now.strftime("%B %Y") %></p>
4
+ </div>
5
+
6
+ <div class="th-stats">
7
+ <div class="th-card">
8
+ <div class="th-card-label">Month to Date</div>
9
+ <div class="th-card-value"><%= number_to_currency(@monthly_total / 100.0) %></div>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="th-section">
14
+ <div class="th-section-header">Top Domains</div>
15
+ <% if @top_domains.empty? %>
16
+ <div class="th-empty">No data recorded yet.</div>
17
+ <% else %>
18
+ <table class="th-table">
19
+ <thead>
20
+ <tr><th>Domain</th><th>Cost</th></tr>
21
+ </thead>
22
+ <tbody>
23
+ <% @top_domains.each do |domain, cost| %>
24
+ <tr>
25
+ <td class="th-mono"><%= domain %></td>
26
+ <td><%= number_to_currency(cost / 100.0) %></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ <% end %>
32
+ </div>
33
+
34
+ <div class="th-section">
35
+ <div class="th-section-header">Daily Costs</div>
36
+ <% if @daily_costs.empty? %>
37
+ <div class="th-empty">No data recorded yet.</div>
38
+ <% else %>
39
+ <table class="th-table">
40
+ <thead>
41
+ <tr><th>Date</th><th>Cost</th></tr>
42
+ </thead>
43
+ <tbody>
44
+ <% @daily_costs.each do |date, cost| %>
45
+ <tr>
46
+ <td class="th-dim"><%= date %></td>
47
+ <td><%= number_to_currency(cost / 100.0) %></td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ <% end %>
53
+ </div>
@@ -0,0 +1,48 @@
1
+ <div class="th-page-header">
2
+ <h1 class="th-page-title"><%= @domain %></h1>
3
+ <p class="th-page-subtitle">Domain detail</p>
4
+ </div>
5
+
6
+ <div class="th-stats">
7
+ <div class="th-card">
8
+ <div class="th-card-label">Total Cost</div>
9
+ <div class="th-card-value"><%= number_to_currency(@total_cost / 100.0) %></div>
10
+ </div>
11
+ <div class="th-card">
12
+ <div class="th-card-label">Calls</div>
13
+ <div class="th-card-value"><%= @call_count %></div>
14
+ </div>
15
+ <div class="th-card">
16
+ <div class="th-card-label">Avg per Call</div>
17
+ <div class="th-card-value"><%= number_to_currency(@cost_per_call / 100.0) %></div>
18
+ </div>
19
+ </div>
20
+
21
+ <% if @top_tags.any? %>
22
+ <div class="th-section">
23
+ <div class="th-section-header">Top Tags</div>
24
+ <div class="th-tag-list">
25
+ <% @top_tags.first(10).each do |key, value| %>
26
+ <span class="th-tag"><%= key %>: <%= value %></span>
27
+ <% end %>
28
+ </div>
29
+ </div>
30
+ <% end %>
31
+
32
+ <div class="th-section">
33
+ <div class="th-section-header">Recent Calls</div>
34
+ <table class="th-table">
35
+ <thead>
36
+ <tr><th>Time</th><th>Model</th><th>Cost</th></tr>
37
+ </thead>
38
+ <tbody>
39
+ <% @recent_calls.each do |call| %>
40
+ <tr>
41
+ <td class="th-dim"><%= call.created_at.strftime("%Y-%m-%d %H:%M") %></td>
42
+ <td class="th-mono"><%= call.model %></td>
43
+ <td><%= number_to_currency(call.total_cost_cents / 100.0) %></td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </div>
@@ -0,0 +1,27 @@
1
+ <div class="th-page-header">
2
+ <h1 class="th-page-title">Pricing Reference</h1>
3
+ </div>
4
+
5
+ <div class="th-section">
6
+ <div class="th-note">Rates reflect prices at the time this gem version was released. Check provider docs for current rates.</div>
7
+ <table class="th-table">
8
+ <thead>
9
+ <tr>
10
+ <th>Model</th>
11
+ <th>Vendor</th>
12
+ <th>Input / 1M tokens</th>
13
+ <th>Output / 1M tokens</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <% @rates.each do |model, rate| %>
18
+ <tr>
19
+ <td class="th-mono"><%= model %></td>
20
+ <td class="th-dim"><%= rate[:vendor] %></td>
21
+ <td><%= number_to_currency(rate[:input]) %></td>
22
+ <td><%= number_to_currency(rate[:output]) %></td>
23
+ </tr>
24
+ <% end %>
25
+ </tbody>
26
+ </table>
27
+ </div>
@@ -0,0 +1,36 @@
1
+ <div class="th-page-header">
2
+ <h1 class="th-page-title">Recent Calls</h1>
3
+ </div>
4
+
5
+ <div class="th-section">
6
+ <% if @calls.empty? %>
7
+ <div class="th-empty">No calls recorded yet.</div>
8
+ <% else %>
9
+ <table class="th-table">
10
+ <thead>
11
+ <tr>
12
+ <th>Time</th>
13
+ <th>Domain</th>
14
+ <th>Model</th>
15
+ <th>In</th>
16
+ <th>Out</th>
17
+ <th>Cost</th>
18
+ <th>Latency</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% @calls.each do |call| %>
23
+ <tr>
24
+ <td class="th-dim"><%= call.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
25
+ <td class="th-mono"><%= call.domain %></td>
26
+ <td class="th-mono"><%= call.model %></td>
27
+ <td><%= number_with_delimiter(call.input_tokens) %></td>
28
+ <td><%= number_with_delimiter(call.output_tokens) %></td>
29
+ <td><%= number_to_currency(call.total_cost_cents / 100.0) %></td>
30
+ <td class="th-dim"><%= call.latency_ms %>ms</td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <% end %>
36
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ TokenHawk::Engine.routes.draw do
4
+ root to: "dashboard#index"
5
+ get "domains/:domain", to: "domains#show", as: :domain
6
+ get "recent", to: "recent#index"
7
+ get "pricing", to: "pricing#index"
8
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTokenHawkCalls < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :token_hawk_calls do |t|
6
+ t.string :domain, null: false
7
+ t.jsonb :tags, null: false, default: {}
8
+ t.string :vendor, null: false
9
+ t.string :model, null: false
10
+ t.integer :input_tokens, null: false
11
+ t.integer :output_tokens, null: false
12
+ t.integer :total_cost_cents, null: false
13
+ t.integer :latency_ms, null: false
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :token_hawk_calls, :domain
19
+ add_index :token_hawk_calls, :created_at
20
+ add_index :token_hawk_calls, :tags, using: :gin
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTokenHawkLlmRates < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :token_hawk_llm_rates do |t|
6
+ t.string :identifier, null: false
7
+ t.string :vendor, null: false
8
+ t.decimal :input_rate, null: false, precision: 10, scale: 6
9
+ t.decimal :output_rate, null: false, precision: 10, scale: 6
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :token_hawk_llm_rates, :identifier, unique: true
15
+ end
16
+ end
data/docs/cli.md ADDED
@@ -0,0 +1,98 @@
1
+ # CLI Reference
2
+
3
+ The `token_hawk` executable is installed with the gem. Run it from any directory where your app's database is accessible (i.e., with `DATABASE_URL` set or inside a Rails environment).
4
+
5
+ ---
6
+
7
+ ## `costs`
8
+
9
+ Show LLM spend grouped by domain, day, or tag.
10
+
11
+ ```bash
12
+ token_hawk costs [options]
13
+ ```
14
+
15
+ ### Options
16
+
17
+ | Flag | Default | Description |
18
+ |------|---------|-------------|
19
+ | `--by` | `domain` | Group by `domain`, `day`, or `tag` |
20
+ | `--domain NAME` | — | Filter to a specific domain |
21
+ | `--since DATE` | — | ISO 8601 date; exclude records before this date |
22
+ | `--tag KEY=VALUE` | — | Filter by tag value, or specify tag key when `--by tag` |
23
+ | `--format` | `text` | `text` or `json` |
24
+
25
+ ### Examples
26
+
27
+ ```bash
28
+ # Monthly spend by domain
29
+ token_hawk costs
30
+
31
+ # Daily spend for the last 30 days
32
+ token_hawk costs --by day --since 2026-08-01
33
+
34
+ # Spend for one domain
35
+ token_hawk costs --domain invoice_extraction
36
+
37
+ # Spend per customer (multi-tenant)
38
+ token_hawk costs --by tag --tag customer_id
39
+
40
+ # Filter to one customer
41
+ token_hawk costs --tag customer_id=42
42
+
43
+ # JSON for scripting
44
+ token_hawk costs --format json | jq '.[0]'
45
+ ```
46
+
47
+ ---
48
+
49
+ ## `efficiency`
50
+
51
+ Show cost per call by domain, sorted descending. Surfaces domains that are expensive on average, not just by volume.
52
+
53
+ ```bash
54
+ token_hawk efficiency [options]
55
+ ```
56
+
57
+ ### Options
58
+
59
+ | Flag | Default | Description |
60
+ |------|---------|-------------|
61
+ | `--format` | `text` | `text` or `json` |
62
+
63
+ ### Example
64
+
65
+ ```bash
66
+ token_hawk efficiency
67
+
68
+ pdf_summarizer $0.42/call 14 calls
69
+ invoice_extraction $0.13/call 312 calls
70
+ chat_support $0.01/call 87 calls
71
+ ```
72
+
73
+ ---
74
+
75
+ ## `recent`
76
+
77
+ Show the most recent LLM calls. Useful for verifying attribution after adding `TokenHawk.track` to a new call site.
78
+
79
+ ```bash
80
+ token_hawk recent [options]
81
+ ```
82
+
83
+ ### Options
84
+
85
+ | Flag | Default | Description |
86
+ |------|---------|-------------|
87
+ | `--limit N` | `20` | Number of records to show |
88
+ | `--format` | `text` | `text` or `json` |
89
+
90
+ ### Example
91
+
92
+ ```bash
93
+ token_hawk recent --limit 5
94
+
95
+ 2026-09-01 14:22:01 invoice_extraction claude-sonnet-4-6 18¢
96
+ 2026-09-01 14:21:44 chat_support gpt-4o-mini 2¢
97
+ 2026-09-01 14:20:11 invoice_extraction claude-sonnet-4-6 21¢
98
+ ```
@@ -0,0 +1,90 @@
1
+ # Configuration
2
+
3
+ TokenHawk is configured via `TokenHawk.configure` in an initializer.
4
+
5
+ ```ruby
6
+ # config/initializers/token_hawk.rb
7
+ TokenHawk.configure do |config|
8
+ config.storage = :active_record
9
+ config.log_failures = true
10
+ config.failure_logger = ->(msg) { Rails.logger.warn(msg) }
11
+ end
12
+ ```
13
+
14
+ ---
15
+
16
+ ## Options
17
+
18
+ ### `storage`
19
+
20
+ **Default:** `:active_record`
21
+
22
+ Controls where telemetry is persisted.
23
+
24
+ | Value | Description |
25
+ |-------|-------------|
26
+ | `:active_record` | Writes to the `token_hawk_calls` table. Requires the migration to have been run. |
27
+ | `:memory` | In-process array. Resets on restart. Use in tests only. |
28
+
29
+ ```ruby
30
+ config.storage = :active_record
31
+ ```
32
+
33
+ ---
34
+
35
+ ### `log_failures`
36
+
37
+ **Default:** `true`
38
+
39
+ When `true`, exceptions raised during telemetry recording are passed to `failure_logger` rather than silently swallowed.
40
+
41
+ ```ruby
42
+ config.log_failures = true
43
+ ```
44
+
45
+ ---
46
+
47
+ ### `failure_logger`
48
+
49
+ **Default:** `->(msg) { warn msg }`
50
+
51
+ A callable that receives a single string message whenever a telemetry failure occurs. Swap this out to route warnings through Rails logger or your observability stack.
52
+
53
+ ```ruby
54
+ config.failure_logger = ->(msg) { Rails.logger.warn("[TokenHawk] #{msg}") }
55
+ ```
56
+
57
+ ---
58
+
59
+ ### `pricing`
60
+
61
+ **Default:** `{}`
62
+
63
+ A hash of custom or override pricing entries. Use this when you're using a model not in TokenHawk's built-in table, or when a provider has changed rates and you're on an older gem version.
64
+
65
+ ```ruby
66
+ config.pricing["my-fine-tuned-model"] = {
67
+ vendor: "openai",
68
+ input: 0.5, # dollars per 1M input tokens
69
+ output: 1.5 # dollars per 1M output tokens
70
+ }
71
+ ```
72
+
73
+ Built-in rates are used as fallback. Custom entries take precedence.
74
+
75
+ ---
76
+
77
+ ## Test configuration
78
+
79
+ In `spec/support/token_hawk.rb` or similar:
80
+
81
+ ```ruby
82
+ TokenHawk.configure do |config|
83
+ config.storage = :memory
84
+ config.log_failures = false
85
+ end
86
+
87
+ RSpec.configure do |config|
88
+ config.before(:each) { TokenHawk.storage_backend.clear }
89
+ end
90
+ ```