@pi-in-go/pigpen-pi-typesafe-api 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.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +30 -0
- package/ask.go +62 -0
- package/ask_test.go +76 -0
- package/auth.go +249 -0
- package/auth_test.go +131 -0
- package/backends.go +336 -0
- package/backends_test.go +404 -0
- package/batch.go +202 -0
- package/batch_test.go +202 -0
- package/battery_test.go +41 -0
- package/calibrate.go +354 -0
- package/calibrate_test.go +186 -0
- package/client.go +615 -0
- package/client_test.go +490 -0
- package/credentials.go +252 -0
- package/credentials_test.go +216 -0
- package/doc.go +14 -0
- package/errors.go +143 -0
- package/evaluation.go +86 -0
- package/evaluation_schema.json +264 -0
- package/gaps_test.go +77 -0
- package/go.mod +9 -0
- package/go.sum +2 -0
- package/helpers_test.go +169 -0
- package/hostmodel/hostmodel.go +87 -0
- package/json.go +299 -0
- package/json_test.go +92 -0
- package/ownmodel_test.go +79 -0
- package/package.json +40 -0
- package/port/PORT.md +6 -0
- package/provenance.json +18 -0
- package/review_test.go +23 -0
- package/schema.go +473 -0
- package/schema_test.go +262 -0
- package/testdata/tools/typebox-messages.mts +5 -0
- package/testdata/typebox-messages.json +285 -0
- package/twin_test.go +28 -0
- package/ui/fakehost_test.go +548 -0
- package/ui/keyprompt.go +115 -0
- package/ui/login.go +106 -0
- package/ui/twin_test.go +28 -0
- package/ui/ui_test.go +285 -0
- package/usage.go +366 -0
- package/usage_test.go +139 -0
package/usage.go
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"math"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"regexp"
|
|
10
|
+
"sort"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// DefaultUSDPerMTok mirrors the $42-per-billion-input-token rate the original's README quotes, so a spend cap
|
|
18
|
+
// means something before anyone configures a price. TypeSafe bills input tokens only; output is free.
|
|
19
|
+
const DefaultUSDPerMTok = 0.042
|
|
20
|
+
|
|
21
|
+
const (
|
|
22
|
+
keepDays = 31
|
|
23
|
+
usageVersion = 1
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// UsageTotals are counters for one window (a session, or one local day).
|
|
27
|
+
type UsageTotals struct {
|
|
28
|
+
RequestsStarted int `json:"requestsStarted"`
|
|
29
|
+
RequestsSucceeded int `json:"requestsSucceeded"`
|
|
30
|
+
RequestsFailed int `json:"requestsFailed"`
|
|
31
|
+
InputTokens int `json:"inputTokens"`
|
|
32
|
+
OutputTokens int `json:"outputTokens"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// UsageReport is one day of persisted totals plus the cost estimate for that day.
|
|
36
|
+
type UsageReport struct {
|
|
37
|
+
UsageTotals
|
|
38
|
+
Day string
|
|
39
|
+
EstimatedUSD float64
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// SpendCaps are client-side spend caps. A zero field is unlimited. A cap that is reached raises a budget
|
|
43
|
+
// error before the next request leaves the process.
|
|
44
|
+
type SpendCaps struct {
|
|
45
|
+
MaxRequests int
|
|
46
|
+
MaxRequestsPerDay int
|
|
47
|
+
MaxInputTokensPerDay int
|
|
48
|
+
MaxUSDPerDay float64
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// BlockedCapName names a reached cap.
|
|
52
|
+
type BlockedCapName string
|
|
53
|
+
|
|
54
|
+
// The day caps.
|
|
55
|
+
const (
|
|
56
|
+
CapRequestsPerDay BlockedCapName = "requestsPerDay"
|
|
57
|
+
CapInputTokensPerDay BlockedCapName = "inputTokensPerDay"
|
|
58
|
+
CapUSDPerDay BlockedCapName = "usdPerDay"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// BlockedCap is the cap that stops the next request, with what it allows and what has been used today.
|
|
62
|
+
type BlockedCap struct {
|
|
63
|
+
Cap BlockedCapName
|
|
64
|
+
Limit float64
|
|
65
|
+
Used float64
|
|
66
|
+
Day string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var dayPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
|
70
|
+
|
|
71
|
+
// LocalDay is the local calendar day of t as YYYY-MM-DD.
|
|
72
|
+
func LocalDay(t time.Time) string { return t.Format("2006-01-02") }
|
|
73
|
+
|
|
74
|
+
// UsagePath is the stored ledger, alongside the key store so one directory holds every file of this package.
|
|
75
|
+
func UsagePath() string { return filepath.Join(TypeSafeDir(), "usage.json") }
|
|
76
|
+
|
|
77
|
+
// EstimateUSD is the input-token cost, rounded to a micro-dollar so the number stays readable.
|
|
78
|
+
func EstimateUSD(inputTokens int, usdPerMTok float64) float64 {
|
|
79
|
+
return math.Floor(float64(inputTokens)*usdPerMTok/1e6*1e6+0.5) / 1e6
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// EmptyTotals returns zeroed counters.
|
|
83
|
+
func EmptyTotals() UsageTotals { return UsageTotals{} }
|
|
84
|
+
|
|
85
|
+
// CapsFromEnvironment reads the caps a headless run may set without code: PI_TYPESAFE_MAX_USD_PER_DAY and its
|
|
86
|
+
// siblings. getenv nil uses the process environment. Values that are not positive numbers are ignored.
|
|
87
|
+
func CapsFromEnvironment(getenv func(string) string) SpendCaps {
|
|
88
|
+
if getenv == nil {
|
|
89
|
+
getenv = os.Getenv
|
|
90
|
+
}
|
|
91
|
+
number := func(name string) float64 {
|
|
92
|
+
raw := strings.TrimSpace(getenv(name))
|
|
93
|
+
if raw == "" {
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
v, err := strconv.ParseFloat(raw, 64)
|
|
97
|
+
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 {
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
return v
|
|
101
|
+
}
|
|
102
|
+
return SpendCaps{
|
|
103
|
+
MaxRequestsPerDay: int(math.Floor(number("PI_TYPESAFE_MAX_REQUESTS_PER_DAY"))),
|
|
104
|
+
MaxInputTokensPerDay: int(math.Floor(number("PI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAY"))),
|
|
105
|
+
MaxUSDPerDay: number("PI_TYPESAFE_MAX_USD_PER_DAY"),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MergeCaps combines an explicit cap with the environment's; the lowest set value wins, so the environment may
|
|
110
|
+
// lower an explicit cap and never raise it. The session cap comes from the explicit caps only.
|
|
111
|
+
func MergeCaps(explicit, environment SpendCaps) SpendCaps {
|
|
112
|
+
lowestInt := func(a, b int) int {
|
|
113
|
+
switch {
|
|
114
|
+
case a == 0:
|
|
115
|
+
return b
|
|
116
|
+
case b == 0:
|
|
117
|
+
return a
|
|
118
|
+
}
|
|
119
|
+
return min(a, b)
|
|
120
|
+
}
|
|
121
|
+
lowestFloat := func(a, b float64) float64 {
|
|
122
|
+
switch {
|
|
123
|
+
case a == 0:
|
|
124
|
+
return b
|
|
125
|
+
case b == 0:
|
|
126
|
+
return a
|
|
127
|
+
}
|
|
128
|
+
return math.Min(a, b)
|
|
129
|
+
}
|
|
130
|
+
return SpendCaps{
|
|
131
|
+
MaxRequests: explicit.MaxRequests,
|
|
132
|
+
MaxRequestsPerDay: lowestInt(explicit.MaxRequestsPerDay, environment.MaxRequestsPerDay),
|
|
133
|
+
MaxInputTokensPerDay: lowestInt(explicit.MaxInputTokensPerDay, environment.MaxInputTokensPerDay),
|
|
134
|
+
MaxUSDPerDay: lowestFloat(explicit.MaxUSDPerDay, environment.MaxUSDPerDay),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// LedgerOptions configure OpenUsageLedger.
|
|
139
|
+
type LedgerOptions struct {
|
|
140
|
+
// Path defaults to UsagePath(); tests point it at a temporary file.
|
|
141
|
+
Path string
|
|
142
|
+
// Now is the clock for the local day and for rollover; injectable for tests.
|
|
143
|
+
Now func() time.Time
|
|
144
|
+
UsdPerMTok float64
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// UsageLedger is one day of persisted request, token, and cost totals, plus the caps that stop the next
|
|
148
|
+
// request. The in-memory copy is authoritative for this process; the file is the cross-process,
|
|
149
|
+
// across-restart record. Reads are defensive, writes are atomic and best-effort, and a day rolls over on the
|
|
150
|
+
// local date, so a long eval cannot accumulate forever unnoticed. It is safe for concurrent use.
|
|
151
|
+
type UsageLedger interface {
|
|
152
|
+
Path() string
|
|
153
|
+
UsdPerMTok() float64
|
|
154
|
+
// Today returns today's totals, after any rollover.
|
|
155
|
+
Today() UsageReport
|
|
156
|
+
// RecordStart counts the attempt before it is submitted; a request that never returns still counts.
|
|
157
|
+
RecordStart()
|
|
158
|
+
RecordSuccess(inputTokens, outputTokens int)
|
|
159
|
+
RecordFailure()
|
|
160
|
+
// Blocked returns the reached day cap that blocks the next request, or nil. The caller owns the caps.
|
|
161
|
+
Blocked(caps SpendCaps) *BlockedCap
|
|
162
|
+
// Describe is one line for status output: today's requests, tokens, and cost, with the caps that apply.
|
|
163
|
+
Describe(caps SpendCaps) string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type fileLedger struct {
|
|
167
|
+
mu sync.Mutex
|
|
168
|
+
path string
|
|
169
|
+
now func() time.Time
|
|
170
|
+
usdPerMTok float64
|
|
171
|
+
day string
|
|
172
|
+
days map[string]UsageTotals
|
|
173
|
+
totals UsageTotals
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// OpenUsageLedger opens (or starts) the ledger file.
|
|
177
|
+
func OpenUsageLedger(opts LedgerOptions) UsageLedger {
|
|
178
|
+
l := &fileLedger{path: opts.Path, now: opts.Now, usdPerMTok: opts.UsdPerMTok}
|
|
179
|
+
if l.path == "" {
|
|
180
|
+
l.path = UsagePath()
|
|
181
|
+
}
|
|
182
|
+
if l.now == nil {
|
|
183
|
+
l.now = time.Now
|
|
184
|
+
}
|
|
185
|
+
if l.usdPerMTok <= 0 {
|
|
186
|
+
l.usdPerMTok = DefaultUSDPerMTok
|
|
187
|
+
}
|
|
188
|
+
l.day = LocalDay(l.now())
|
|
189
|
+
l.days = keepRecent(readDays(l.path), l.day)
|
|
190
|
+
l.totals = l.days[l.day]
|
|
191
|
+
return l
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func count(v any) int {
|
|
195
|
+
f, ok := v.(float64)
|
|
196
|
+
if !ok || f < 0 || f != math.Floor(f) || f > 1<<53-1 {
|
|
197
|
+
return 0
|
|
198
|
+
}
|
|
199
|
+
return int(f)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func totalsOf(v any) UsageTotals {
|
|
203
|
+
raw, _ := v.(map[string]any)
|
|
204
|
+
return UsageTotals{
|
|
205
|
+
RequestsStarted: count(raw["requestsStarted"]),
|
|
206
|
+
RequestsSucceeded: count(raw["requestsSucceeded"]),
|
|
207
|
+
RequestsFailed: count(raw["requestsFailed"]),
|
|
208
|
+
InputTokens: count(raw["inputTokens"]),
|
|
209
|
+
OutputTokens: count(raw["outputTokens"]),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// readDays is defensive: a missing, unreadable, or corrupt ledger restarts today's count; it never blocks a request.
|
|
214
|
+
func readDays(path string) map[string]UsageTotals {
|
|
215
|
+
out := map[string]UsageTotals{}
|
|
216
|
+
data, err := os.ReadFile(path)
|
|
217
|
+
if err != nil {
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
var parsed map[string]any
|
|
221
|
+
if json.Unmarshal(data, &parsed) != nil {
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
days, ok := parsed["days"].(map[string]any)
|
|
225
|
+
if !ok {
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
for day, totals := range days {
|
|
229
|
+
if dayPattern.MatchString(day) {
|
|
230
|
+
out[day] = totalsOf(totals)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func keepRecent(days map[string]UsageTotals, today string) map[string]UsageTotals {
|
|
237
|
+
names := make([]string, 0, len(days))
|
|
238
|
+
for n := range days {
|
|
239
|
+
names = append(names, n)
|
|
240
|
+
}
|
|
241
|
+
sort.Strings(names)
|
|
242
|
+
if len(names) > keepDays {
|
|
243
|
+
names = names[len(names)-keepDays:]
|
|
244
|
+
}
|
|
245
|
+
out := map[string]UsageTotals{}
|
|
246
|
+
for _, n := range names {
|
|
247
|
+
out[n] = days[n]
|
|
248
|
+
}
|
|
249
|
+
if _, ok := days[today]; ok {
|
|
250
|
+
out[today] = days[today]
|
|
251
|
+
} else {
|
|
252
|
+
out[today] = UsageTotals{}
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// writeDays is owner-only, atomic, and best-effort: a ledger this process cannot write never fails a request.
|
|
258
|
+
func writeDays(path string, days map[string]UsageTotals) {
|
|
259
|
+
body, err := json.MarshalIndent(map[string]any{"version": usageVersion, "days": days}, "", " ")
|
|
260
|
+
if err != nil {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
_ = writeOwnerOnly(path, append(body, '\n'))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func (l *fileLedger) Path() string { return l.path }
|
|
267
|
+
func (l *fileLedger) UsdPerMTok() float64 { return l.usdPerMTok }
|
|
268
|
+
|
|
269
|
+
func (l *fileLedger) report(t UsageTotals, day string) UsageReport {
|
|
270
|
+
return UsageReport{UsageTotals: t, Day: day, EstimatedUSD: EstimateUSD(t.InputTokens, l.usdPerMTok)}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func (l *fileLedger) save() {
|
|
274
|
+
l.days = keepRecent(mergeDay(l.days, l.day, l.totals), l.day)
|
|
275
|
+
writeDays(l.path, l.days)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
func mergeDay(days map[string]UsageTotals, day string, totals UsageTotals) map[string]UsageTotals {
|
|
279
|
+
out := make(map[string]UsageTotals, len(days)+1)
|
|
280
|
+
for k, v := range days {
|
|
281
|
+
out[k] = v
|
|
282
|
+
}
|
|
283
|
+
out[day] = totals
|
|
284
|
+
return out
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
func (l *fileLedger) roll() {
|
|
288
|
+
current := LocalDay(l.now())
|
|
289
|
+
if current == l.day {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
l.day = current
|
|
293
|
+
l.totals = l.days[l.day]
|
|
294
|
+
l.days = keepRecent(l.days, l.day)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func (l *fileLedger) add(d UsageTotals) {
|
|
298
|
+
l.mu.Lock()
|
|
299
|
+
defer l.mu.Unlock()
|
|
300
|
+
l.roll()
|
|
301
|
+
l.totals = UsageTotals{
|
|
302
|
+
RequestsStarted: l.totals.RequestsStarted + d.RequestsStarted,
|
|
303
|
+
RequestsSucceeded: l.totals.RequestsSucceeded + d.RequestsSucceeded,
|
|
304
|
+
RequestsFailed: l.totals.RequestsFailed + d.RequestsFailed,
|
|
305
|
+
InputTokens: l.totals.InputTokens + d.InputTokens,
|
|
306
|
+
OutputTokens: l.totals.OutputTokens + d.OutputTokens,
|
|
307
|
+
}
|
|
308
|
+
l.save()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
func (l *fileLedger) Today() UsageReport {
|
|
312
|
+
l.mu.Lock()
|
|
313
|
+
defer l.mu.Unlock()
|
|
314
|
+
l.roll()
|
|
315
|
+
return l.report(l.totals, l.day)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func (l *fileLedger) RecordStart() { l.add(UsageTotals{RequestsStarted: 1}) }
|
|
319
|
+
func (l *fileLedger) RecordSuccess(in, out int) {
|
|
320
|
+
l.add(UsageTotals{RequestsSucceeded: 1, InputTokens: max(in, 0), OutputTokens: max(out, 0)})
|
|
321
|
+
}
|
|
322
|
+
func (l *fileLedger) RecordFailure() { l.add(UsageTotals{RequestsFailed: 1}) }
|
|
323
|
+
|
|
324
|
+
func (l *fileLedger) Blocked(caps SpendCaps) *BlockedCap {
|
|
325
|
+
l.mu.Lock()
|
|
326
|
+
defer l.mu.Unlock()
|
|
327
|
+
l.roll()
|
|
328
|
+
checks := []struct {
|
|
329
|
+
cap BlockedCapName
|
|
330
|
+
limit float64
|
|
331
|
+
used float64
|
|
332
|
+
}{
|
|
333
|
+
{CapRequestsPerDay, float64(caps.MaxRequestsPerDay), float64(l.totals.RequestsStarted)},
|
|
334
|
+
{CapInputTokensPerDay, float64(caps.MaxInputTokensPerDay), float64(l.totals.InputTokens)},
|
|
335
|
+
{CapUSDPerDay, caps.MaxUSDPerDay, EstimateUSD(l.totals.InputTokens, l.usdPerMTok)},
|
|
336
|
+
}
|
|
337
|
+
for _, c := range checks {
|
|
338
|
+
if c.limit > 0 && c.used >= c.limit {
|
|
339
|
+
return &BlockedCap{Cap: c.cap, Limit: c.limit, Used: c.used, Day: l.day}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return nil
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func (l *fileLedger) Describe(caps SpendCaps) string {
|
|
346
|
+
l.mu.Lock()
|
|
347
|
+
defer l.mu.Unlock()
|
|
348
|
+
l.roll()
|
|
349
|
+
cur := l.report(l.totals, l.day)
|
|
350
|
+
var limits []string
|
|
351
|
+
if caps.MaxRequestsPerDay > 0 {
|
|
352
|
+
limits = append(limits, fmt.Sprintf("%d/%d requests", cur.RequestsStarted, caps.MaxRequestsPerDay))
|
|
353
|
+
}
|
|
354
|
+
if caps.MaxInputTokensPerDay > 0 {
|
|
355
|
+
limits = append(limits, fmt.Sprintf("%d/%d input tokens", cur.InputTokens, caps.MaxInputTokensPerDay))
|
|
356
|
+
}
|
|
357
|
+
if caps.MaxUSDPerDay > 0 {
|
|
358
|
+
limits = append(limits, fmt.Sprintf("$%.4f/$%.2f", cur.EstimatedUSD, caps.MaxUSDPerDay))
|
|
359
|
+
}
|
|
360
|
+
tail := "; no daily cap"
|
|
361
|
+
if len(limits) > 0 {
|
|
362
|
+
tail = "; caps " + strings.Join(limits, ", ")
|
|
363
|
+
}
|
|
364
|
+
return fmt.Sprintf("%d requests today (%d ok, %d failed), %d input / %d output tokens, ~$%.4f%s",
|
|
365
|
+
cur.RequestsStarted, cur.RequestsSucceeded, cur.RequestsFailed, cur.InputTokens, cur.OutputTokens, cur.EstimatedUSD, tail)
|
|
366
|
+
}
|
package/usage_test.go
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func at(day, hour int) time.Time {
|
|
12
|
+
return time.Date(2026, time.January, day, hour, 0, 0, 0, time.Local)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func TestUsage(t *testing.T) {
|
|
16
|
+
tw(t, "usage", "a ledger counts requests, tokens, and failures, and prices input tokens only", func(t *testing.T) {
|
|
17
|
+
l := OpenUsageLedger(LedgerOptions{Path: filepath.Join(t.TempDir(), "counts.json"), Now: func() time.Time { return at(1, 12) }})
|
|
18
|
+
if got := l.Today(); got != (UsageReport{Day: "2026-01-01"}) {
|
|
19
|
+
t.Fatalf("empty ledger = %+v", got)
|
|
20
|
+
}
|
|
21
|
+
l.RecordStart()
|
|
22
|
+
l.RecordSuccess(42, 7)
|
|
23
|
+
l.RecordStart()
|
|
24
|
+
l.RecordFailure()
|
|
25
|
+
today := l.Today()
|
|
26
|
+
if today.RequestsStarted != 2 || today.RequestsSucceeded != 1 || today.RequestsFailed != 1 || today.InputTokens != 42 || today.OutputTokens != 7 || today.Day != "2026-01-01" {
|
|
27
|
+
t.Fatalf("totals = %+v", today)
|
|
28
|
+
}
|
|
29
|
+
// Output tokens are free; only input tokens carry a price.
|
|
30
|
+
if today.EstimatedUSD != EstimateUSD(42, DefaultUSDPerMTok) {
|
|
31
|
+
t.Errorf("estimate = %v", today.EstimatedUSD)
|
|
32
|
+
}
|
|
33
|
+
d := l.Describe(SpendCaps{})
|
|
34
|
+
if !contains(d, "2 requests today (1 ok, 1 failed)") || !contains(d, "~$0.0000") {
|
|
35
|
+
t.Errorf("describe = %q", d)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
tw(t, "usage", "totals survive a new ledger instance, so a restart does not reset the day", func(t *testing.T) {
|
|
39
|
+
path := filepath.Join(t.TempDir(), "persist.json")
|
|
40
|
+
now := func() time.Time { return at(2, 12) }
|
|
41
|
+
first := OpenUsageLedger(LedgerOptions{Path: path, Now: now})
|
|
42
|
+
first.RecordStart()
|
|
43
|
+
first.RecordSuccess(1000, 0)
|
|
44
|
+
second := OpenUsageLedger(LedgerOptions{Path: path, Now: now})
|
|
45
|
+
if got := second.Today(); got.RequestsStarted != 1 || got.InputTokens != 1000 || got.EstimatedUSD != EstimateUSD(1000, DefaultUSDPerMTok) {
|
|
46
|
+
t.Fatalf("reopened = %+v", got)
|
|
47
|
+
}
|
|
48
|
+
info, err := os.Stat(path)
|
|
49
|
+
if err != nil || info.Mode().Perm() != 0o600 {
|
|
50
|
+
t.Fatalf("mode = %v, %v", info, err)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
tw(t, "usage", "the day rolls over on the local date and old days are kept", func(t *testing.T) {
|
|
54
|
+
day := 3
|
|
55
|
+
path := filepath.Join(t.TempDir(), "rollover.json")
|
|
56
|
+
l := OpenUsageLedger(LedgerOptions{Path: path, Now: func() time.Time { return at(day, 12) }})
|
|
57
|
+
l.RecordStart()
|
|
58
|
+
l.RecordSuccess(500, 0)
|
|
59
|
+
day = 4
|
|
60
|
+
if got := l.Today(); got.Day != "2026-01-04" || got.RequestsStarted != 0 || got.InputTokens != 0 {
|
|
61
|
+
t.Fatalf("after rollover = %+v", got)
|
|
62
|
+
}
|
|
63
|
+
l.RecordStart()
|
|
64
|
+
var file struct {
|
|
65
|
+
Days map[string]UsageTotals `json:"days"`
|
|
66
|
+
}
|
|
67
|
+
data, _ := os.ReadFile(path)
|
|
68
|
+
if err := json.Unmarshal(data, &file); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
if file.Days["2026-01-03"].InputTokens != 500 || file.Days["2026-01-04"].RequestsStarted != 1 {
|
|
72
|
+
t.Fatalf("file = %s", data)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
tw(t, "usage", "each cap stops the next request and names itself", func(t *testing.T) {
|
|
76
|
+
dir := t.TempDir()
|
|
77
|
+
now := func() time.Time { return at(5, 12) }
|
|
78
|
+
counting := OpenUsageLedger(LedgerOptions{Path: filepath.Join(dir, "r.json"), Now: now})
|
|
79
|
+
requests := SpendCaps{MaxRequestsPerDay: 1}
|
|
80
|
+
if counting.Blocked(requests) != nil {
|
|
81
|
+
t.Fatal("blocked before any request")
|
|
82
|
+
}
|
|
83
|
+
counting.RecordStart()
|
|
84
|
+
if got := counting.Blocked(requests); got == nil || *got != (BlockedCap{Cap: CapRequestsPerDay, Limit: 1, Used: 1, Day: "2026-01-05"}) {
|
|
85
|
+
t.Fatalf("blocked = %+v", got)
|
|
86
|
+
}
|
|
87
|
+
tokens := OpenUsageLedger(LedgerOptions{Path: filepath.Join(dir, "t.json"), Now: now})
|
|
88
|
+
tokens.RecordSuccess(100, 0)
|
|
89
|
+
if got := tokens.Blocked(SpendCaps{MaxInputTokensPerDay: 100}); got == nil || got.Cap != CapInputTokensPerDay {
|
|
90
|
+
t.Fatalf("blocked = %+v", got)
|
|
91
|
+
}
|
|
92
|
+
usd := OpenUsageLedger(LedgerOptions{Path: filepath.Join(dir, "u.json"), Now: now, UsdPerMTok: 1_000_000})
|
|
93
|
+
usd.RecordSuccess(1, 0)
|
|
94
|
+
if got := usd.Blocked(SpendCaps{MaxUSDPerDay: 0.5}); got == nil || got.Cap != CapUSDPerDay || got.Used != 1 {
|
|
95
|
+
t.Fatalf("blocked = %+v", got)
|
|
96
|
+
}
|
|
97
|
+
// The caps belong to the caller: the same totals block under one cap and pass under another.
|
|
98
|
+
if usd.Blocked(SpendCaps{MaxUSDPerDay: 10}) != nil {
|
|
99
|
+
t.Fatal("a higher cap must not block")
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
tw(t, "usage", "caps come from the environment without ever raising the explicit cap", func(t *testing.T) {
|
|
103
|
+
env := map[string]string{"PI_TYPESAFE_MAX_REQUESTS_PER_DAY": "500", "PI_TYPESAFE_MAX_USD_PER_DAY": "2.5", "PI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAY": "nonsense"}
|
|
104
|
+
get := func(k string) string { return env[k] }
|
|
105
|
+
environment := CapsFromEnvironment(get)
|
|
106
|
+
if environment != (SpendCaps{MaxRequestsPerDay: 500, MaxUSDPerDay: 2.5}) {
|
|
107
|
+
t.Fatalf("environment = %+v", environment)
|
|
108
|
+
}
|
|
109
|
+
if got := MergeCaps(SpendCaps{MaxRequests: 20, MaxUSDPerDay: 1}, environment); got != (SpendCaps{MaxRequests: 20, MaxRequestsPerDay: 500, MaxUSDPerDay: 1}) {
|
|
110
|
+
t.Fatalf("merged = %+v", got)
|
|
111
|
+
}
|
|
112
|
+
if MergeCaps(SpendCaps{}, SpendCaps{}) != (SpendCaps{}) || CapsFromEnvironment(func(string) string { return "" }) != (SpendCaps{}) {
|
|
113
|
+
t.Fatal("empty caps must stay empty")
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
tw(t, "usage", "a corrupt, unreadable, or foreign ledger never blocks a request", func(t *testing.T) {
|
|
117
|
+
path := filepath.Join(t.TempDir(), "corrupt.json")
|
|
118
|
+
now := func() time.Time { return at(6, 12) }
|
|
119
|
+
writeFile(t, path, "{ not json", 0o600)
|
|
120
|
+
l := OpenUsageLedger(LedgerOptions{Path: path, Now: now})
|
|
121
|
+
if l.Today().RequestsStarted != 0 {
|
|
122
|
+
t.Fatal("corrupt ledger must restart the count")
|
|
123
|
+
}
|
|
124
|
+
l.RecordStart()
|
|
125
|
+
if OpenUsageLedger(LedgerOptions{Path: path, Now: now}).Today().RequestsStarted != 1 {
|
|
126
|
+
t.Fatal("the rewritten ledger must persist")
|
|
127
|
+
}
|
|
128
|
+
writeFile(t, path, `{"version":1,"days":{"2026-01-06":{"requestsStarted":"many"},"notADay":{}}}`, 0o600)
|
|
129
|
+
if OpenUsageLedger(LedgerOptions{Path: path, Now: now}).Today().RequestsStarted != 0 {
|
|
130
|
+
t.Fatal("a foreign ledger must read as zero")
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
tw(t, "usage", "the default usage path sits with the key store", func(t *testing.T) {
|
|
134
|
+
dir := isolate(t)
|
|
135
|
+
if UsagePath() != filepath.Join(dir, "pi-typesafe", "usage.json") || LocalDay(at(7, 12)) != "2026-01-07" {
|
|
136
|
+
t.Fatalf("path = %s", UsagePath())
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|