@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.
Files changed (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
@@ -0,0 +1,615 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "io"
8
+ "math"
9
+ "net/http"
10
+ "strconv"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
16
+ )
17
+
18
+ const (
19
+ sdkPath = "/v1/systemone"
20
+ sdkModelsPath = "/v1/models"
21
+ // DefaultMaxRequests is the default number of attempts per client instance; the extension quotes the same number in its consent copy.
22
+ DefaultMaxRequests = 20
23
+ // DefaultTimeout is the default per-request timeout.
24
+ DefaultTimeout = 15 * time.Second
25
+ )
26
+
27
+ // backendDoer sends the SDK's fixed paths to the backend's own, preserving any caller-supplied transport. A
28
+ // backend that serves its model list under another path also gets that list renamed to the field the SDK reads.
29
+ type backendDoer struct {
30
+ inner typesafe.HTTPDoer
31
+ backend ResolvedBackend
32
+ }
33
+
34
+ func (d backendDoer) Do(req *http.Request) (*http.Response, error) {
35
+ inner := d.inner
36
+ if inner == nil {
37
+ inner = http.DefaultClient
38
+ }
39
+ models := d.backend.ModelsPath != "" && strings.Contains(req.URL.Path, sdkModelsPath)
40
+ rewrite := d.backend.Path
41
+ from := sdkPath
42
+ if models {
43
+ rewrite, from = d.backend.ModelsPath, sdkModelsPath
44
+ }
45
+ if rewrite != "" {
46
+ clone := req.Clone(req.Context())
47
+ u := *req.URL
48
+ u.Path = strings.Replace(u.Path, from, rewrite, 1)
49
+ u.RawPath = ""
50
+ clone.URL = &u
51
+ req = clone
52
+ }
53
+ resp, err := inner.Do(req)
54
+ if err != nil || !models || d.backend.ModelsField == "" {
55
+ return resp, err
56
+ }
57
+ return translateModels(resp, d.backend)
58
+ }
59
+
60
+ // translateModels hands the SDK the list it expects: the field it reads, and the entry value callers pass as
61
+ // model when the backend labels models differently. Status and headers survive; a body without the declared
62
+ // field is passed through unchanged, so the SDK still reports its own shape error.
63
+ func translateModels(resp *http.Response, backend ResolvedBackend) (*http.Response, error) {
64
+ text, err := io.ReadAll(resp.Body)
65
+ resp.Body.Close()
66
+ if err != nil {
67
+ return nil, err
68
+ }
69
+ send := func(body []byte) *http.Response {
70
+ out := *resp
71
+ out.Header = resp.Header.Clone()
72
+ // The body is replaced, so a copied length would describe the old one.
73
+ out.Header.Del("Content-Length")
74
+ out.Header.Del("Content-Encoding")
75
+ out.ContentLength = int64(len(body))
76
+ out.Body = io.NopCloser(bytes.NewReader(body))
77
+ return &out
78
+ }
79
+ wire, perr := ParseJSON(text)
80
+ obj, isObj := wire.(*Object)
81
+ if perr != nil || !isObj {
82
+ return send(text), nil
83
+ }
84
+ list, _ := obj.Get(backend.ModelsField)
85
+ entries, isList := list.([]any)
86
+ if !isList {
87
+ return send(text), nil
88
+ }
89
+ models := make([]any, len(entries))
90
+ for i, e := range entries {
91
+ entry, ok := e.(*Object)
92
+ if backend.ModelsIDField == "" || !ok {
93
+ models[i] = e
94
+ continue
95
+ }
96
+ id, _ := entry.Get(backend.ModelsIDField)
97
+ if s, ok := id.(string); ok && s != "" {
98
+ c := entry.Clone()
99
+ c.Set("name", s)
100
+ models[i] = c
101
+ } else {
102
+ models[i] = e
103
+ }
104
+ }
105
+ wrapper := NewObject()
106
+ wrapper.Set("models", models)
107
+ body, err := EncodeJSON(wrapper)
108
+ if err != nil {
109
+ return nil, err
110
+ }
111
+ return send(body), nil
112
+ }
113
+
114
+ // Options configure New.
115
+ type Options struct {
116
+ // APIKey defaults to the backend's key (TYPESAFE_API_KEY, then the key saved by /typesafe login for the
117
+ // TypeSafe backend); it is never returned.
118
+ APIKey string
119
+ // Backend is the judgment backend: a registry name or a caller-supplied endpoint. Nil routes to the default TypeSafe host.
120
+ Backend any
121
+ // Model defaults to the backend's own default (jev-latest, typesafe/jev-1.13 on OpenRouter); a bare Jev id is
122
+ // mapped to the backend's id form before sending. No model is inferred from submitted content.
123
+ Model string
124
+ // Timeout is per request. Default: 15 seconds. There are no automatic retries.
125
+ Timeout time.Duration
126
+ // MaxInputBytes is the UTF-8 JSON bytes including model and questions. Default: 64 KiB. Not a token limit.
127
+ MaxInputBytes int
128
+ // MaxRequests is the attempts per client instance, including failed network requests. Default: 20.
129
+ MaxRequests int
130
+ // MaxRequestsPerDay is requests per local day, counted across processes and restarts. Unlimited by default.
131
+ MaxRequestsPerDay int
132
+ // MaxInputTokensPerDay is input tokens per local day. Unlimited by default.
133
+ MaxInputTokensPerDay int
134
+ // MaxUSDPerDay is estimated spend per local day, in US dollars. Unlimited by default.
135
+ MaxUSDPerDay float64
136
+ // UsdPerMTok is the price used for the cost estimate and the USD cap. Default: DefaultUSDPerMTok.
137
+ UsdPerMTok float64
138
+ // Ledger is the usage ledger; it defaults to the store next to the key. Injected by tests.
139
+ Ledger UsageLedger
140
+ // HTTPClient is a transport injection for extension authors and offline tests.
141
+ HTTPClient typesafe.HTTPDoer
142
+ // Evaluator answers the questions instead of a TypeSafe client. The own-model backend requires one (an
143
+ // ownmodel.Backend on the model PiG is configured with); on any other backend it replaces the HTTP client.
144
+ Evaluator typesafe.Evaluator
145
+ }
146
+
147
+ // Evaluation is a typed result plus the time it took and the question ids in request order.
148
+ type Evaluation struct {
149
+ *typesafe.SystemOneResult
150
+ ElapsedMs int64
151
+ // Order lists the answer ids in the order the questions were asked; Go maps have none.
152
+ Order []string
153
+ }
154
+
155
+ // UsageSnapshot holds session counters for one client instance, plus the cost estimate they add up to.
156
+ type UsageSnapshot struct {
157
+ RequestsStarted int
158
+ RequestsSucceeded int
159
+ RequestsFailed int
160
+ InputTokens int
161
+ OutputTokens int
162
+ EstimatedUSD float64
163
+ }
164
+
165
+ // SpendReport holds session counters, today's persisted counters, the caps in force, and the cap that is currently reached.
166
+ type SpendReport struct {
167
+ Session UsageSnapshot
168
+ Today UsageReport
169
+ Caps SpendCaps
170
+ UsdPerMTok float64
171
+ Blocked *BlockedCap
172
+ }
173
+
174
+ // TypeSafe is a bounded client independent of PiG's runtime.
175
+ type TypeSafe struct {
176
+ backend ResolvedBackend
177
+ evaluator typesafe.Evaluator
178
+ client *typesafe.Client
179
+ local bool
180
+ model string
181
+ timeout time.Duration
182
+ maxInputBytes int
183
+ maxRequests int
184
+ usdPerMTok float64
185
+ caps SpendCaps
186
+ ledger UsageLedger
187
+
188
+ mu sync.Mutex
189
+ usage UsageSnapshot
190
+ verificationRecorded bool
191
+ lastFailureRecorded string
192
+ }
193
+
194
+ func positiveInt(v int, def int, label string) (int, error) {
195
+ if v == 0 {
196
+ return def, nil
197
+ }
198
+ if v < 0 {
199
+ return 0, errorf(CodeConfiguration, "%s must be a positive safe integer.", label)
200
+ }
201
+ return v, nil
202
+ }
203
+
204
+ func positiveNumber(v, def float64, label string) (float64, error) {
205
+ if v == 0 {
206
+ return def, nil
207
+ }
208
+ if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
209
+ return 0, errorf(CodeConfiguration, "%s must be a positive number.", label)
210
+ }
211
+ return v, nil
212
+ }
213
+
214
+ // New builds a client. It reads the key, validates every option, and never sends anything.
215
+ func New(opts Options) (*TypeSafe, error) {
216
+ backend, err := ResolveBackend(opts.Backend)
217
+ if err != nil {
218
+ return nil, err
219
+ }
220
+ c := &TypeSafe{backend: backend, local: backend.Local, evaluator: opts.Evaluator}
221
+ apiKey := strings.TrimSpace(opts.APIKey)
222
+ if !c.local {
223
+ if apiKey == "" {
224
+ // The same resolution that GetAuthState reports, so the status line and the request agree.
225
+ situation, err := KeySituationFor(opts.Backend)
226
+ if err != nil {
227
+ return nil, err
228
+ }
229
+ switch situation.Kind {
230
+ case KeyUnusable:
231
+ return nil, newError(CodeConfiguration, situation.Reason)
232
+ case KeyEnvironment, KeyStored:
233
+ apiKey = situation.Key
234
+ }
235
+ }
236
+ if apiKey == "" {
237
+ how := "Set " + backend.KeyEnv
238
+ if UsesTypeSafeKey(backend.BackendConfig) {
239
+ how = "Run /typesafe login in PiG, or set " + typesafeKeyEnv
240
+ }
241
+ return nil, newError(CodeConfiguration, "No API key. "+how+" in the environment.")
242
+ }
243
+ } else if opts.Evaluator == nil {
244
+ return nil, newError(CodeConfiguration, `The "ownmodel" backend answers with the model PiG is configured with; pass Evaluator (an ownmodel.Backend).`)
245
+ }
246
+ if c.timeout, err = durationOrDefault(opts.Timeout, DefaultTimeout, "timeoutMs"); err != nil {
247
+ return nil, err
248
+ }
249
+ if c.maxInputBytes, err = positiveInt(opts.MaxInputBytes, DefaultMaxInputBytes, "maxInputBytes"); err != nil {
250
+ return nil, err
251
+ }
252
+ if c.maxRequests, err = positiveInt(opts.MaxRequests, DefaultMaxRequests, "maxRequests"); err != nil {
253
+ return nil, err
254
+ }
255
+ if c.usdPerMTok, err = positiveNumber(opts.UsdPerMTok, DefaultUSDPerMTok, "usdPerMTok"); err != nil {
256
+ return nil, err
257
+ }
258
+ explicit := SpendCaps{MaxRequests: c.maxRequests}
259
+ if explicit.MaxRequestsPerDay, err = positiveInt(opts.MaxRequestsPerDay, 0, "maxRequestsPerDay"); err != nil {
260
+ return nil, err
261
+ }
262
+ if explicit.MaxInputTokensPerDay, err = positiveInt(opts.MaxInputTokensPerDay, 0, "maxInputTokensPerDay"); err != nil {
263
+ return nil, err
264
+ }
265
+ if explicit.MaxUSDPerDay, err = positiveNumber(opts.MaxUSDPerDay, 0, "maxUsdPerDay"); err != nil {
266
+ return nil, err
267
+ }
268
+ c.caps = MergeCaps(explicit, CapsFromEnvironment(nil))
269
+ // The caller's input is validated as written, then mapped to the backend's id form; omitting it sends the
270
+ // backend's own default, which the mapping leaves unchanged.
271
+ requested := opts.Model
272
+ if requested == "" {
273
+ requested = backend.DefaultModel
274
+ }
275
+ if requested == "" && !c.local {
276
+ return nil, errorf(CodeConfiguration, "Backend %q names no defaultModel; pass Model to New.", backend.Label)
277
+ }
278
+ if utf16Len(requested) > 100 || (requested != "" && strings.TrimSpace(requested) == "") {
279
+ return nil, newError(CodeConfiguration, "model must be a nonempty string of at most 100 characters.")
280
+ }
281
+ c.model = c.mapModel(requested)
282
+ c.ledger = opts.Ledger
283
+ if c.ledger == nil {
284
+ c.ledger = OpenUsageLedger(LedgerOptions{UsdPerMTok: c.usdPerMTok})
285
+ }
286
+ if c.evaluator == nil {
287
+ transport := opts.HTTPClient
288
+ if backend.Path != "" || backend.ModelsPath != "" {
289
+ transport = backendDoer{inner: opts.HTTPClient, backend: backend}
290
+ }
291
+ // Do not inherit SDK debug logging or alternate destinations from the environment.
292
+ client, err := typesafe.NewClient(typesafe.Config{
293
+ APIKey: apiKey,
294
+ BaseURL: backend.Host,
295
+ DefaultModel: c.model,
296
+ LogLevel: typesafe.LogOff,
297
+ Retry: typesafe.RetryOverrides{MaxRetries: typesafe.Ptr(0)},
298
+ Timeout: c.timeout,
299
+ HTTPClient: transport,
300
+ Getenv: func(string) string { return "" },
301
+ })
302
+ if err != nil {
303
+ return nil, SafeError(err, opts.Backend)
304
+ }
305
+ c.client, c.evaluator = client, client
306
+ }
307
+ return c, nil
308
+ }
309
+
310
+ func durationOrDefault(v, def time.Duration, label string) (time.Duration, error) {
311
+ if v == 0 {
312
+ return def, nil
313
+ }
314
+ if v < 0 {
315
+ return 0, errorf(CodeConfiguration, "%s must be a positive safe integer.", label)
316
+ }
317
+ return v, nil
318
+ }
319
+
320
+ // mapModel maps a model to the backend's id form. Only a registry backend maps ids; a caller-supplied
321
+ // endpoint's model is sent as the caller wrote it.
322
+ func (c *TypeSafe) mapModel(model string) string {
323
+ if c.backend.Name == "" {
324
+ return model
325
+ }
326
+ return BackendModelID(c.backend.Name, model)
327
+ }
328
+
329
+ // Backend is the resolved backend this client sends to.
330
+ func (c *TypeSafe) Backend() ResolvedBackend { return c.backend }
331
+
332
+ // Model is the model id sent when a request names none.
333
+ func (c *TypeSafe) Model() string { return c.model }
334
+
335
+ func (c *TypeSafe) snapshotLocked() UsageSnapshot {
336
+ s := c.usage
337
+ s.EstimatedUSD = EstimateUSD(s.InputTokens, c.usdPerMTok)
338
+ return s
339
+ }
340
+
341
+ // GetUsage returns this client's session counters.
342
+ func (c *TypeSafe) GetUsage() UsageSnapshot {
343
+ c.mu.Lock()
344
+ defer c.mu.Unlock()
345
+ return c.snapshotLocked()
346
+ }
347
+
348
+ func (c *TypeSafe) blockedLocked() *BlockedCap {
349
+ if c.usage.RequestsStarted >= c.maxRequests {
350
+ return nil
351
+ }
352
+ return c.ledger.Blocked(c.caps)
353
+ }
354
+
355
+ // GetSpend returns session counters, today's persisted totals, and the caps that stop the next request.
356
+ func (c *TypeSafe) GetSpend() SpendReport {
357
+ c.mu.Lock()
358
+ defer c.mu.Unlock()
359
+ return SpendReport{Session: c.snapshotLocked(), Today: c.ledger.Today(), Caps: c.caps, UsdPerMTok: c.usdPerMTok, Blocked: c.blockedLocked()}
360
+ }
361
+
362
+ var capLabels = map[BlockedCapName]string{
363
+ CapRequestsPerDay: "daily request cap",
364
+ CapInputTokensPerDay: "daily input-token cap",
365
+ CapUSDPerDay: "daily spend cap",
366
+ }
367
+
368
+ func capsDescription(caps SpendCaps) string {
369
+ var parts []string
370
+ if caps.MaxRequests > 0 {
371
+ parts = append(parts, fmt.Sprintf("%d per session", caps.MaxRequests))
372
+ }
373
+ if caps.MaxRequestsPerDay > 0 {
374
+ parts = append(parts, fmt.Sprintf("%d requests per day", caps.MaxRequestsPerDay))
375
+ }
376
+ if caps.MaxInputTokensPerDay > 0 {
377
+ parts = append(parts, fmt.Sprintf("%d input tokens per day", caps.MaxInputTokensPerDay))
378
+ }
379
+ if caps.MaxUSDPerDay > 0 {
380
+ parts = append(parts, "$"+jsNumber(caps.MaxUSDPerDay)+" per day")
381
+ }
382
+ if len(parts) == 0 {
383
+ return "No request or spend caps are set."
384
+ }
385
+ return "Caps: " + strings.Join(parts, ", ") + "."
386
+ }
387
+
388
+ // ListModels returns the model names available to the account. It verifies the key and does not count toward
389
+ // MaxRequests. The own-model backend has no list: its model is the one PiG is configured with.
390
+ func (c *TypeSafe) ListModels(ctx context.Context) ([]string, error) {
391
+ if c.client == nil {
392
+ return nil, newError(CodeConfiguration, "This backend has no model list: the model is the one PiG is configured with.")
393
+ }
394
+ // The list is read leniently, as the original does: an entry without a usable name is skipped, not an error.
395
+ raw, err := c.client.Models().ListRaw(ctx, nil)
396
+ if err != nil {
397
+ return nil, SafeError(err, c.specForErrors())
398
+ }
399
+ tree, perr := ParseJSON(raw.Body)
400
+ wire, _ := tree.(*Object)
401
+ var list any
402
+ if perr == nil && wire != nil {
403
+ v, _ := wire.Get("models")
404
+ list = v
405
+ }
406
+ entries, isList := list.([]any)
407
+ if !isList {
408
+ return nil, newError(CodeResponse, "TypeSafe returned an unexpected model list.")
409
+ }
410
+ // A backend that serves its list publicly accepts any key, so a success there proves nothing about one.
411
+ if c.backend.ModelsVerifyKey {
412
+ c.recordVerifiedOnce()
413
+ }
414
+ names := []string{}
415
+ for _, e := range entries {
416
+ card, _ := e.(*Object)
417
+ if card == nil {
418
+ continue
419
+ }
420
+ if name, ok := card.vals["name"].(string); ok && name != "" && utf16Len(name) <= 100 {
421
+ names = append(names, name)
422
+ }
423
+ }
424
+ return names, nil
425
+ }
426
+
427
+ // specForErrors is the backend as SafeError wants it: the registry name when there is one, else the resolved endpoint.
428
+ func (c *TypeSafe) specForErrors() any {
429
+ if c.backend.Name != "" {
430
+ return c.backend.Name
431
+ }
432
+ return map[string]any{"label": c.backend.Label, "host": c.backend.Host, "keyEnv": c.backend.KeyEnv}
433
+ }
434
+
435
+ func (c *TypeSafe) recordVerifiedOnce() {
436
+ if c.local {
437
+ return
438
+ }
439
+ c.mu.Lock()
440
+ first := !c.verificationRecorded
441
+ c.verificationRecorded = true
442
+ c.mu.Unlock()
443
+ if first {
444
+ RecordAuthVerified(time.Now())
445
+ }
446
+ }
447
+
448
+ // Evaluate sends one typed request through the admission rule and the caps.
449
+ func (c *TypeSafe) Evaluate(ctx context.Context, request typesafe.SystemOneRequest) (*Evaluation, error) {
450
+ return c.EvaluateRaw(ctx, request)
451
+ }
452
+
453
+ // EvaluateRaw is Evaluate for a request that arrives as untyped JSON (a decoded map, an *Object, a
454
+ // json.RawMessage): the tool call and the playground. It admits the same near-miss aliases as the tool.
455
+ func (c *TypeSafe) EvaluateRaw(ctx context.Context, input any) (*Evaluation, error) {
456
+ validated, err := PrepareEvaluationRequest(input, PrepareOptions{MaxInputBytes: c.maxInputBytes})
457
+ if err != nil {
458
+ return nil, err
459
+ }
460
+ // A per-request model meets the same mapping as the client default; the schema already limited the caller's own id.
461
+ model := c.model
462
+ if validated.Model != "" {
463
+ model = c.mapModel(validated.Model)
464
+ }
465
+ sent := validated.WithModel(model)
466
+ body, err := sent.MarshalJSON()
467
+ if err != nil {
468
+ return nil, errorf(CodeValidation, "Invalid evaluation request: %s", jsonSafetyMessage)
469
+ }
470
+ if err := AssertWithinByteLimit(body, c.maxInputBytes); err != nil {
471
+ return nil, err
472
+ }
473
+ if ctx.Err() != nil {
474
+ return nil, newError(CodeAborted, "TypeSafe request cancelled before submission.")
475
+ }
476
+ // Snapshot before awaiting so later mutations cannot change the request or validation.
477
+ typed, err := sent.Typed()
478
+ if err != nil {
479
+ return nil, errorf(CodeValidation, "Invalid evaluation request: %s", jsonSafetyMessage)
480
+ }
481
+ c.mu.Lock()
482
+ if c.usage.RequestsStarted >= c.maxRequests {
483
+ c.mu.Unlock()
484
+ return nil, errorf(CodeBudget, "TypeSafe request limit reached (%d attempts per client instance). %s", c.maxRequests, capsDescription(c.caps))
485
+ }
486
+ if reached := c.ledger.Blocked(c.caps); reached != nil {
487
+ c.mu.Unlock()
488
+ // Name the cap, the used amount, and the day, so a long run stops loudly instead of burning tokens unnoticed.
489
+ return nil, errorf(CodeBudget, "TypeSafe %s reached (%s of %s on %s); no request was submitted. Requests resume after the local day rolls over, or raise the cap deliberately.",
490
+ capLabels[reached.Cap], jsNumber(reached.Used), jsNumber(reached.Limit), reached.Day)
491
+ }
492
+ c.usage.RequestsStarted++
493
+ c.ledger.RecordStart()
494
+ c.mu.Unlock()
495
+
496
+ start := time.Now()
497
+ callCtx := ctx
498
+ if c.client == nil || c.evaluator != typesafe.Evaluator(c.client) {
499
+ var cancel context.CancelFunc
500
+ callCtx, cancel = context.WithTimeout(ctx, c.timeout)
501
+ defer cancel()
502
+ }
503
+ result, err := c.evaluator.SystemOne(callCtx, typed, nil)
504
+ if err == nil {
505
+ order := sent.Questions.Keys()
506
+ if !validResult(result, sent.Questions) {
507
+ err = newError(CodeResponse, "TypeSafe returned an unexpected answer or usage format.")
508
+ } else {
509
+ c.mu.Lock()
510
+ c.usage.RequestsSucceeded++
511
+ c.usage.InputTokens += result.Usage.InputTokens
512
+ c.usage.OutputTokens += result.Usage.OutputTokens
513
+ c.ledger.RecordSuccess(result.Usage.InputTokens, result.Usage.OutputTokens)
514
+ c.mu.Unlock()
515
+ c.recordVerifiedOnce()
516
+ return &Evaluation{SystemOneResult: result, ElapsedMs: time.Since(start).Milliseconds(), Order: order}, nil
517
+ }
518
+ }
519
+ if callCtx != ctx && callCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil {
520
+ err = context.DeadlineExceeded
521
+ }
522
+ safe := SafeError(err, c.specForErrors())
523
+ // The request was submitted, so it counts even when it fails; the reason stays visible in GetAuthState.
524
+ c.mu.Lock()
525
+ c.usage.RequestsFailed++
526
+ c.ledger.RecordFailure()
527
+ fingerprint := fmt.Sprintf("%s:%s:%s", safe.Code, statusText(safe.Status), safe.Message)
528
+ record := c.lastFailureRecorded != fingerprint
529
+ c.lastFailureRecorded = fingerprint
530
+ c.mu.Unlock()
531
+ if record && !c.local {
532
+ RecordAuthFailure(safe, time.Now())
533
+ }
534
+ return nil, safe
535
+ }
536
+
537
+ func statusText(status int) string {
538
+ if status == 0 {
539
+ return ""
540
+ }
541
+ return strconv.Itoa(status)
542
+ }
543
+
544
+ // EvaluateMany sends several requests with bounded concurrency; answers, usage, and model merged. It never fails.
545
+ func (c *TypeSafe) EvaluateMany(ctx context.Context, requests []typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
546
+ return EvaluateMany(ctx, c, requests, opts)
547
+ }
548
+
549
+ // EvaluateAll takes one state and any number of questions: chunk to the per-request limit, then fan out. It never fails.
550
+ func (c *TypeSafe) EvaluateAll(ctx context.Context, request typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
551
+ return EvaluateAll(ctx, c, request, opts)
552
+ }
553
+
554
+ var _ Judge = (*TypeSafe)(nil)
555
+ var _ Evaluator = (*TypeSafe)(nil)
556
+
557
+ // validResult checks a result against the questions that were asked.
558
+ func validResult(result *typesafe.SystemOneResult, questions *Object) bool {
559
+ probability := func(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1 }
560
+ if result == nil || result.Model == "" || result.Answers == nil {
561
+ return false
562
+ }
563
+ if result.Usage.InputTokens < 0 || result.Usage.OutputTokens < 0 {
564
+ return false
565
+ }
566
+ if len(result.Answers) != questions.Len() {
567
+ return false
568
+ }
569
+ for _, id := range questions.keys {
570
+ q, _ := questions.vals[id].(*Object)
571
+ kind, _ := q.vals["type"].(string)
572
+ answer, ok := result.Answers[id]
573
+ if !ok || answer == nil || string(answer.AnswerType()) != kind {
574
+ return false
575
+ }
576
+ criteria, _ := q.Get("criteria")
577
+ switch a := answer.(type) {
578
+ case typesafe.NoulAnswer:
579
+ if !probability(a.Noul) {
580
+ return false
581
+ }
582
+ case typesafe.ChoiceAnswer:
583
+ labels, _ := criteria.(*Object)
584
+ if labels == nil || !probability(a.Confidence) || a.Probabilities == nil || len(a.Probabilities) != labels.Len() {
585
+ return false
586
+ }
587
+ for _, key := range labels.keys {
588
+ p, ok := a.Probabilities[key]
589
+ if !ok || !probability(p) {
590
+ return false
591
+ }
592
+ }
593
+ if !labels.Has(a.Choice) {
594
+ return false
595
+ }
596
+ case typesafe.ScoreAnswer:
597
+ levels, _ := criteria.([]any)
598
+ if !probability(a.Confidence) || a.Probabilities == nil || len(a.Probabilities) != len(levels) {
599
+ return false
600
+ }
601
+ for i := range levels {
602
+ p, ok := a.Probabilities[i]
603
+ if !ok || !probability(p) {
604
+ return false
605
+ }
606
+ }
607
+ if math.IsNaN(a.Score) || math.IsInf(a.Score, 0) || a.Score < 0 || a.Score > float64(len(levels)-1) || a.Legend == nil {
608
+ return false
609
+ }
610
+ default:
611
+ return false
612
+ }
613
+ }
614
+ return true
615
+ }