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
|
@@ -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,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
|
+
```
|