@pi-in-go/pigpen-pi-typesafe 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 +45 -0
- package/extensions/pi-typesafe/branches_test.go +185 -0
- package/extensions/pi-typesafe/command.go +319 -0
- package/extensions/pi-typesafe/export_test.go +9 -0
- package/extensions/pi-typesafe/extension.go +188 -0
- package/extensions/pi-typesafe/extension_test.go +321 -0
- package/extensions/pi-typesafe/fakehost_test.go +548 -0
- package/extensions/pi-typesafe/format.go +191 -0
- package/extensions/pi-typesafe/format_test.go +75 -0
- package/extensions/pi-typesafe/go.mod +10 -0
- package/extensions/pi-typesafe/go.sum +2 -0
- package/extensions/pi-typesafe/go.work +11 -0
- package/extensions/pi-typesafe/harness_test.go +200 -0
- package/extensions/pi-typesafe/ownmodel_test.go +100 -0
- package/extensions/pi-typesafe/review_test.go +134 -0
- package/extensions/pi-typesafe/tool.go +193 -0
- package/extensions/pi-typesafe/twin_test.go +28 -0
- package/libs/pi-typesafe-api/CREDITS.md +14 -0
- package/libs/pi-typesafe-api/LICENSE +22 -0
- package/libs/pi-typesafe-api/README.md +30 -0
- package/libs/pi-typesafe-api/ask.go +62 -0
- package/libs/pi-typesafe-api/ask_test.go +76 -0
- package/libs/pi-typesafe-api/auth.go +249 -0
- package/libs/pi-typesafe-api/auth_test.go +131 -0
- package/libs/pi-typesafe-api/backends.go +336 -0
- package/libs/pi-typesafe-api/backends_test.go +404 -0
- package/libs/pi-typesafe-api/batch.go +202 -0
- package/libs/pi-typesafe-api/batch_test.go +202 -0
- package/libs/pi-typesafe-api/battery_test.go +41 -0
- package/libs/pi-typesafe-api/calibrate.go +354 -0
- package/libs/pi-typesafe-api/calibrate_test.go +186 -0
- package/libs/pi-typesafe-api/client.go +615 -0
- package/libs/pi-typesafe-api/client_test.go +490 -0
- package/libs/pi-typesafe-api/credentials.go +252 -0
- package/libs/pi-typesafe-api/credentials_test.go +216 -0
- package/libs/pi-typesafe-api/doc.go +14 -0
- package/libs/pi-typesafe-api/errors.go +143 -0
- package/libs/pi-typesafe-api/evaluation.go +86 -0
- package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
- package/libs/pi-typesafe-api/gaps_test.go +77 -0
- package/libs/pi-typesafe-api/go.mod +9 -0
- package/libs/pi-typesafe-api/go.sum +2 -0
- package/libs/pi-typesafe-api/helpers_test.go +169 -0
- package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
- package/libs/pi-typesafe-api/json.go +299 -0
- package/libs/pi-typesafe-api/json_test.go +92 -0
- package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
- package/libs/pi-typesafe-api/package.json +40 -0
- package/libs/pi-typesafe-api/provenance.json +18 -0
- package/libs/pi-typesafe-api/review_test.go +23 -0
- package/libs/pi-typesafe-api/schema.go +473 -0
- package/libs/pi-typesafe-api/schema_test.go +262 -0
- package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
- package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
- package/libs/pi-typesafe-api/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
- package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
- package/libs/pi-typesafe-api/ui/login.go +106 -0
- package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
- package/libs/pi-typesafe-api/usage.go +366 -0
- package/libs/pi-typesafe-api/usage_test.go +139 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +98 -0
- package/port/accepted-gaps.json +3 -0
- package/port/golden/enable-confirm.jsonl +11 -0
- package/port/golden/enable-decline.jsonl +20 -0
- package/port/golden/enable-missing-key.jsonl +4 -0
- package/port/golden/login-shadow.jsonl +4 -0
- package/port/golden/logout-env-key.jsonl +6 -0
- package/port/golden/playground-cancel.jsonl +4 -0
- package/port/golden/playground-invalid-json.jsonl +5 -0
- package/port/golden/playground-invalid-questions.jsonl +5 -0
- package/port/golden/status-env-key.jsonl +6 -0
- package/port/golden/status-no-key.jsonl +6 -0
- package/port/golden/tool-disabled.jsonl +18 -0
- package/port/golden/trailing-words.jsonl +10 -0
- package/port/library-mutations.py +44 -0
- package/port/mutations.json +302 -0
- package/port/oracle/.env.example +4 -0
- package/port/oracle/CHANGELOG.md +91 -0
- package/port/oracle/CONTRIBUTING.md +35 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +159 -0
- package/port/oracle/docs/api.md +143 -0
- package/port/oracle/docs/ci-cd.md +97 -0
- package/port/oracle/examples/decision-extension.ts +41 -0
- package/port/oracle/extensions/index.js +2 -0
- package/port/oracle/package.json +89 -0
- package/port/oracle/scripts/dev-pi.mjs +23 -0
- package/port/oracle/scripts/live-smoke.mjs +35 -0
- package/port/oracle/src/ask.ts +42 -0
- package/port/oracle/src/auth.ts +171 -0
- package/port/oracle/src/backends.ts +196 -0
- package/port/oracle/src/batch.ts +170 -0
- package/port/oracle/src/calibrate.ts +237 -0
- package/port/oracle/src/client.ts +310 -0
- package/port/oracle/src/credentials.ts +136 -0
- package/port/oracle/src/errors.ts +53 -0
- package/port/oracle/src/extension.ts +204 -0
- package/port/oracle/src/index.ts +31 -0
- package/port/oracle/src/key-prompt.ts +51 -0
- package/port/oracle/src/login.ts +60 -0
- package/port/oracle/src/schema.ts +158 -0
- package/port/oracle/src/ui.ts +4 -0
- package/port/oracle/src/usage.ts +258 -0
- package/port/oracle/tests/ask.test.ts +63 -0
- package/port/oracle/tests/auth.test.ts +141 -0
- package/port/oracle/tests/backends.test.ts +380 -0
- package/port/oracle/tests/batch.test.ts +156 -0
- package/port/oracle/tests/calibrate.test.ts +144 -0
- package/port/oracle/tests/client.test.ts +499 -0
- package/port/oracle/tests/credentials.test.ts +144 -0
- package/port/oracle/tests/extension.test.ts +276 -0
- package/port/oracle/tests/key-prompt.test.ts +47 -0
- package/port/oracle/tests/login.test.ts +101 -0
- package/port/oracle/tests/schema.test.ts +85 -0
- package/port/oracle/tests/usage.test.ts +106 -0
- package/port/oracle/tsconfig.build.json +10 -0
- package/port/oracle/tsconfig.json +14 -0
- package/port/scenarios/enable-confirm.json +5 -0
- package/port/scenarios/enable-decline.json +3 -0
- package/port/scenarios/enable-missing-key.json +2 -0
- package/port/scenarios/login-shadow.json +2 -0
- package/port/scenarios/logout-env-key.json +3 -0
- package/port/scenarios/playground-cancel.json +2 -0
- package/port/scenarios/playground-invalid-json.json +2 -0
- package/port/scenarios/playground-invalid-questions.json +2 -0
- package/port/scenarios/status-env-key.json +3 -0
- package/port/scenarios/status-no-key.json +3 -0
- package/port/scenarios/tool-disabled.json +2 -0
- package/port/scenarios/trailing-words.json +5 -0
- package/port/upstream-tests.json +160 -0
- package/provenance.json +18 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# CONTRACT: the shared Go TypeSafe client
|
|
2
|
+
|
|
3
|
+
Status: **implemented.** The signatures in `libraries/*` were frozen first so that the extensions built on
|
|
4
|
+
this client (`pi-typesafe`, `jev`, `warden`) could write their tests against them; the implementation has
|
|
5
|
+
since landed and those Packages use it. Changes are listed under "Changes since the CONTRACT commit" below.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
The Package is a library Package (root `go.mod`, no extension of its own), like `pig-play`. It uses the
|
|
10
|
+
standard library only.
|
|
11
|
+
|
|
12
|
+
| Import path | Role |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe` | The client: port of `@typesafe-ai/sdk` 0.6.0 (questions, answers, errors, retries, timeouts, logging, models, `Evaluator`, `EvaluateBatch`). |
|
|
15
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel` | The second backend: answers the same questions with a `Model` you give it (port of system-one-adapter-python). Implements `typesafe.Evaluator`. |
|
|
16
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/pigmodel` | A `ownmodel.Model` on the PiG Go SDK model access (`Context.ModelRegistry()`); the model PiG is configured with. |
|
|
17
|
+
|
|
18
|
+
Use it from an extension the way `angry-pigs` uses `pig-play`: the extension's `go.mod` requires
|
|
19
|
+
`github.com/MichaelKinsy/pigpen/components/typesafe v0.0.0` (no `replace`), and its directory has a
|
|
20
|
+
`go.work` with `use ( . ../../../typesafe )`; a Piglet selects the Package under an alias equal to its
|
|
21
|
+
directory name (`typesafe`). See `components/extension-port/skills/pigpen-pi-extension-port/SKILL.md`, step 8.
|
|
22
|
+
|
|
23
|
+
`npm run test:go-ports` picks up this Package's root-level `go.mod`, so it vets and tests it with the other
|
|
24
|
+
modules; `go test ./...` in this directory also works.
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```go
|
|
29
|
+
// The TypeSafe API (TYPESAFE_API_KEY, TYPESAFE_BASE_URL, TYPESAFE_DEFAULT_MODEL, TYPESAFE_LOG_LEVEL).
|
|
30
|
+
client, err := typesafe.NewClient(typesafe.Config{})
|
|
31
|
+
res, err := client.SystemOne(ctx, typesafe.SystemOneRequest{
|
|
32
|
+
State: typesafe.Text("I was charged twice. Please fix this ASAP."),
|
|
33
|
+
Questions: typesafe.Questions{
|
|
34
|
+
typesafe.Ask("billing", typesafe.Noul("Is this about billing?")),
|
|
35
|
+
typesafe.Ask("tone", typesafe.Choice("Tone?", typesafe.Opt("calm", nil), typesafe.Opt("angry", nil))),
|
|
36
|
+
typesafe.Ask("urgency", typesafe.Score("How urgent?", "can wait", "this week", "today")),
|
|
37
|
+
},
|
|
38
|
+
}, nil)
|
|
39
|
+
tone, err := res.Choice("tone") // typed: ChoiceAnswer{Choice, Confidence, Probabilities}
|
|
40
|
+
|
|
41
|
+
// The own model: whatever PiG is configured with.
|
|
42
|
+
info, _ := ctx.GetModelInfo() // PiG Go SDK
|
|
43
|
+
m, _ := pigmodel.New(ctx.ModelRegistry(), pigmodel.Ref{Provider: info.Provider, ID: info.ID})
|
|
44
|
+
own, _ := ownmodel.New(ownmodel.Options{Model: m, AnswerMode: ownmodel.Probabilities})
|
|
45
|
+
|
|
46
|
+
var ev typesafe.Evaluator = own // or client: same request, same result type
|
|
47
|
+
out := typesafe.EvaluateBatch(ctx, ev, items, typesafe.BatchOptions{Concurrency: 4})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The signatures with their documentation are in the source (`go doc` shows them); this document states
|
|
51
|
+
what the doc comments do not: the mapping from the TypeScript SDK and every deliberate difference.
|
|
52
|
+
|
|
53
|
+
## Semantics that are fixed
|
|
54
|
+
|
|
55
|
+
- **Wire format** is the TypeScript SDK's: `POST {baseURL}/v1/systemone` with body
|
|
56
|
+
`{state, questions, model, ...extra}`, `GET {baseURL}/v1/models`; headers `Authorization: Bearer <key>`,
|
|
57
|
+
`Accept: application/json`, `User-Agent`, `X-TypeSafe-SDK`, `X-TypeSafe-Runtime`, `Content-Type: application/json`
|
|
58
|
+
only when there is a body, and `X-TypeSafe-Retry-Count: <n>` on retries. Caller headers cannot replace
|
|
59
|
+
those (case-insensitive); per-call headers win over `DefaultHeaders`.
|
|
60
|
+
- **Omitted vs null** is preserved. `Entry{}` is omitted; `Null` and `Value(nil)` are null. `Noul(x)` sends
|
|
61
|
+
`instructions` (null when x is nil) and omits `criteria`; `.Yes`/`.No` set one side; `.NullCriteria()` sends null.
|
|
62
|
+
- **Validation before sending** (`*TypeSafeError`, texts as in the SDK): no questions ("At least one question is
|
|
63
|
+
required."), a score question with fewer than two criteria ("Score question "q" has N criteria; at least two
|
|
64
|
+
scores are required."), and, added for Go, duplicate names.
|
|
65
|
+
- **Retries**: default 2 retries after the first attempt; 500 ms initial backoff doubling to 5 s; jitter 0.25;
|
|
66
|
+
statuses 408, 429 and 500 to 599; `Retry-After` / `retry-after-ms` honored up to 60 s; connection failures and
|
|
67
|
+
timeouts retried. Cancellation is never retried. **Timeout** is per attempt (10 s), covers headers and body,
|
|
68
|
+
and there is no total budget.
|
|
69
|
+
- **Errors**: `errors.As` targets as drawn in the `TypeSafeError` doc. Messages are the SDK's, for example
|
|
70
|
+
`401 invalid api key`, `Request timed out after 1000ms.`, `No API key was provided...`.
|
|
71
|
+
- **Answers** decode by their `type`; `Noul`, `Choice`, `Score` accessors on the result return an error for a
|
|
72
|
+
missing or mismatched answer. A response with no answers is not an error.
|
|
73
|
+
- **Secrets**: the API key is never exposed by a method, `String`, `%+v`, `%#v`, JSON encoding or logs (the
|
|
74
|
+
`Authorization` header is redacted to `Bearer ***abcd`).
|
|
75
|
+
|
|
76
|
+
## Deliberate differences from the TypeScript SDK
|
|
77
|
+
|
|
78
|
+
| TypeScript | Go | Why |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `AbortSignal` in per-call options | the `context.Context` argument; ending it returns `*APIUserAbortError` | Go idiom |
|
|
81
|
+
| `fetch` option | `Config.HTTPClient` (`HTTPDoer`) | Go idiom |
|
|
82
|
+
| `dangerouslyAllowBrowser`, browser guard, `describeRuntime()` runtime names | not ported; `X-TypeSafe-Runtime` is `go/<version> (<os>; <arch>)` and `X-TypeSafe-SDK` is `typesafe-sdk-go/0.6.0` | no browser; an honest client identity |
|
|
83
|
+
| `APIPromise` (`asResponse`, `withResponse`, `map`, thenable) | `SystemOneWithResponse`, `SystemOneRaw`, `Models.ListWithResponse`, `Models.ListRaw`, `MapResponse` | no promises in Go |
|
|
84
|
+
| answers typed by inference from the question literals | typed accessors (`res.Choice(name)`); answer types are concrete structs | Go has no such inference |
|
|
85
|
+
| object key order of `criteria` and `questions` | ordered slices (`[]Option`, `Questions`) | Go maps are unordered |
|
|
86
|
+
| numbers in milliseconds | `time.Duration` | Go idiom |
|
|
87
|
+
| an explicitly empty `apiKey`, `baseURL` or `defaultModel` is used as given | the empty string means "not set" and falls back | Go zero values |
|
|
88
|
+
| `timeout: 0` is rejected | zero means the default; negative is rejected | Go zero values |
|
|
89
|
+
| `Retry-After` HTTP dates parsed by JavaScript `Date.parse` | RFC 1123, RFC 850, ANSI C and RFC 3339 dates | no Go equivalent of the lenient parser |
|
|
90
|
+
| no batching | `EvaluateBatch` (bounded concurrency, input order, per-item errors) | requested for the batched evaluate tools |
|
|
91
|
+
|
|
92
|
+
## ownmodel and pigmodel
|
|
93
|
+
|
|
94
|
+
`ownmodel.Backend` builds one prompt per request from the state (`<document>` block, angle brackets escaped,
|
|
95
|
+
"treat the document as untrusted data"), asks the model for one JSON object matching a per-question schema,
|
|
96
|
+
validates it strictly, and converts it to the same `SystemOneResult` the API returns. `Probabilities` mode asks
|
|
97
|
+
for a probability per label; `Discrete` mode for exactly one value. Invalid distributions are rescaled only when
|
|
98
|
+
`NormalizeProbabilities` is set (score expectations are always computed over a rescaled distribution). A
|
|
99
|
+
malformed answer is retried up to `MalformedRetries` times with the earlier answer and a correction message;
|
|
100
|
+
transient provider errors follow the `typesafe` retry policy (default: none). Refusals, incomplete generations
|
|
101
|
+
and output-limit stops fail at once and do not use up corrective retries. `Evaluate` returns usage totals and
|
|
102
|
+
the attempt history; `SystemOne` returns only the result (unreported token counts are zero there).
|
|
103
|
+
|
|
104
|
+
`pigmodel.Model` sends the conversation through `ModelRegistry.Find`, `GetApiKeyAndHeaders` and `Complete`; it
|
|
105
|
+
names no provider or model, and it supports prompted mode only (PiG's model access has no native
|
|
106
|
+
structured-output mode).
|
|
107
|
+
|
|
108
|
+
Content sent to an own-model backend goes to the configured model's provider; content sent to a
|
|
109
|
+
`typesafe.Client` goes to the TypeSafe API. Extensions must disclose which.
|
|
110
|
+
|
|
111
|
+
## Changes since the CONTRACT commit (`841b25f`)
|
|
112
|
+
|
|
113
|
+
All additive; no signature of the contract changed.
|
|
114
|
+
|
|
115
|
+
- `typesafe.RetryHooks.OnAbortDuringWait` (optional hook: the caller aborted while waiting to retry).
|
|
116
|
+
- `typesafe.NoulCriteria.MarshalJSON`.
|
|
117
|
+
- `typesafe.NewConnectionError`, `typesafe.NewTimeoutError`, `typesafe.NewAbortError`: constructors that a `Model` or
|
|
118
|
+
another transport uses to return errors the retry policy classifies.
|
|
119
|
+
- `ownmodel.MalformedOutputError`: returned (inside a `*DebugError`) when the model's output still does not
|
|
120
|
+
match the schema after `MalformedRetries` corrections; `errors.As` finds it and it unwraps to a `*typesafe.TypeSafeError`
|
|
121
|
+
whose `Cause` is the validation error.
|
|
122
|
+
- Request bodies are encoded without HTML escaping (`<`, `>`, `&` as written), like the official SDK.
|
|
123
|
+
- (Review) `ParseRetryAfter`, `RetryPolicy.Delay` and `RateLimitError.RetryAfter` saturate at the largest
|
|
124
|
+
`time.Duration` instead of wrapping to a negative value; a huge `Retry-After` now falls back to backoff like the SDK.
|
|
125
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Credits
|
|
2
|
+
|
|
3
|
+
This Package is a Go port of two MIT-licensed projects by TypeSafe AI, and its cross-check uses a third (Apache-2.0). It keeps their
|
|
4
|
+
copyright notices and licenses (in `port/oracle/<name>/LICENSE`).
|
|
5
|
+
|
|
6
|
+
## typesafe-sdk-js
|
|
7
|
+
|
|
8
|
+
`libraries/typesafe` ports **@typesafe-ai/sdk** 0.6.0, the official TypeScript SDK for the
|
|
9
|
+
TypeSafe API: https://github.com/typesafe-ai/typesafe-sdk-js, by **TypeSafe**
|
|
10
|
+
(package author **evinism**), MIT, Copyright (c) 2026 TypeSafe.
|
|
11
|
+
|
|
12
|
+
- Pinned commit: `66880ccded6cb642dc1809620c2b108c33730214` (tag/version 0.6.0)
|
|
13
|
+
- License: [`port/oracle/typesafe-sdk-js/LICENSE`](port/oracle/typesafe-sdk-js/LICENSE)
|
|
14
|
+
|
|
15
|
+
## system-one-adapter-python
|
|
16
|
+
|
|
17
|
+
`libraries/ownmodel` ports **system-one-adapter** 0.2.1, the drop-in `system_one` backend
|
|
18
|
+
that answers TypeSafe questions with an LLM: https://github.com/typesafe-ai/system-one-adapter-python,
|
|
19
|
+
by **TypeSafe AI** (maintainers **Erik Gafni** and **Daniel Gafni**), MIT, Copyright (c) 2026 TypeSafe AI.
|
|
20
|
+
|
|
21
|
+
- Pinned commit: `e1d4cc938204b22fc5a3c3aca7044072fe3f712d`
|
|
22
|
+
- License: [`port/oracle/system-one-adapter-python/LICENSE`](port/oracle/system-one-adapter-python/LICENSE)
|
|
23
|
+
|
|
24
|
+
## WorkflowEvals (cross-check only)
|
|
25
|
+
|
|
26
|
+
https://github.com/typesafe-ai/WorkflowEvals (Apache-2.0, TypeSafe AI; the commits are by Sam) is used only to check the
|
|
27
|
+
Go client against, at commit `0ac3b8ad845429f0d8e064ecfb2430a47c5a25cb` (Apache-2.0, license in
|
|
28
|
+
[`port/oracle/WorkflowEvals/LICENSE`](port/oracle/WorkflowEvals/LICENSE)). The question sets its workflows
|
|
29
|
+
build (`port/crosscheck/workflowevals.json`) and the extraction script that records them are derived from
|
|
30
|
+
its code; no dataset content is stored, and the states in that file are synthetic. No other source of it is
|
|
31
|
+
copied into this Package.
|
|
32
|
+
|
|
33
|
+
## What is modified
|
|
34
|
+
|
|
35
|
+
The Go code, tests and documents were written for this Package by Michael Kinsy; the behavior,
|
|
36
|
+
error texts, defaults, prompts and test cases follow the originals. `port/PORT.md` maps every
|
|
37
|
+
upstream file to its Go counterpart and lists each deliberate difference.
|