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