@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/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
+ }