@pi-in-go/pigpen-pi-typesafe-api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +30 -0
- package/ask.go +62 -0
- package/ask_test.go +76 -0
- package/auth.go +249 -0
- package/auth_test.go +131 -0
- package/backends.go +336 -0
- package/backends_test.go +404 -0
- package/batch.go +202 -0
- package/batch_test.go +202 -0
- package/battery_test.go +41 -0
- package/calibrate.go +354 -0
- package/calibrate_test.go +186 -0
- package/client.go +615 -0
- package/client_test.go +490 -0
- package/credentials.go +252 -0
- package/credentials_test.go +216 -0
- package/doc.go +14 -0
- package/errors.go +143 -0
- package/evaluation.go +86 -0
- package/evaluation_schema.json +264 -0
- package/gaps_test.go +77 -0
- package/go.mod +9 -0
- package/go.sum +2 -0
- package/helpers_test.go +169 -0
- package/hostmodel/hostmodel.go +87 -0
- package/json.go +299 -0
- package/json_test.go +92 -0
- package/ownmodel_test.go +79 -0
- package/package.json +40 -0
- package/port/PORT.md +6 -0
- package/provenance.json +18 -0
- package/review_test.go +23 -0
- package/schema.go +473 -0
- package/schema_test.go +262 -0
- package/testdata/tools/typebox-messages.mts +5 -0
- package/testdata/typebox-messages.json +285 -0
- package/twin_test.go +28 -0
- package/ui/fakehost_test.go +548 -0
- package/ui/keyprompt.go +115 -0
- package/ui/login.go +106 -0
- package/ui/twin_test.go +28 -0
- package/ui/ui_test.go +285 -0
- package/usage.go +366 -0
- package/usage_test.go +139 -0
package/backends.go
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net"
|
|
6
|
+
"net/url"
|
|
7
|
+
"regexp"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Registry names of the judgment backends.
|
|
13
|
+
const (
|
|
14
|
+
BackendTypeSafe = "typesafe"
|
|
15
|
+
BackendOpenRouter = "openrouter"
|
|
16
|
+
BackendCommandCode = "commandcode"
|
|
17
|
+
// BackendOwnModel answers with the model PiG is configured with instead of sending the
|
|
18
|
+
// content to a TypeSafe host: the second backend of the shared client (ownmodel).
|
|
19
|
+
BackendOwnModel = "ownmodel"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// DefaultBackend is the backend every key and auth function assumes when none is named.
|
|
23
|
+
const DefaultBackend = BackendTypeSafe
|
|
24
|
+
|
|
25
|
+
const typesafeKeyEnv = "TYPESAFE_API_KEY"
|
|
26
|
+
|
|
27
|
+
// BackendConfig describes one judgment backend.
|
|
28
|
+
type BackendConfig struct {
|
|
29
|
+
// Label is the human name for status lines: "TypeSafe", "OpenRouter", "Command Code".
|
|
30
|
+
Label string
|
|
31
|
+
Host string
|
|
32
|
+
// KeyEnv is the environment variable that carries this backend's key. Empty means the TypeSafe key resolution applies.
|
|
33
|
+
KeyEnv string
|
|
34
|
+
// Path is the request path, when the backend does not serve the SDK's own /v1/systemone.
|
|
35
|
+
Path string
|
|
36
|
+
// ModelsPath is the model list path, when the backend does not serve the SDK's own /v1/models.
|
|
37
|
+
ModelsPath string
|
|
38
|
+
// ModelsField is the field the model list arrives in, when it is not the SDK's own "models".
|
|
39
|
+
ModelsField string
|
|
40
|
+
// ModelsIDField is the entry field carrying the id callers pass as model, when the SDK's own "name" is only a label.
|
|
41
|
+
ModelsIDField string
|
|
42
|
+
// ModelsVerifyKey says whether the model list checks the key. A public list accepts any key, so it proves nothing. Nil means it does.
|
|
43
|
+
ModelsVerifyKey *bool
|
|
44
|
+
// Local marks a backend that sends nothing to a TypeSafe host (the own-model backend).
|
|
45
|
+
Local bool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// BackendEndpoint is a caller-supplied endpoint that serves the Jev decisions protocol. It is passed per
|
|
49
|
+
// call and never added to the registry.
|
|
50
|
+
type BackendEndpoint struct {
|
|
51
|
+
BackendConfig
|
|
52
|
+
// DefaultModel is sent when the caller names no model. Without it, a model must be passed to New.
|
|
53
|
+
DefaultModel string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ResolvedBackend is a backend resolved and validated: what the client will actually use.
|
|
57
|
+
type ResolvedBackend struct {
|
|
58
|
+
BackendConfig
|
|
59
|
+
// Name is the registry name; empty for a caller-supplied endpoint.
|
|
60
|
+
Name string
|
|
61
|
+
// DefaultModel is the model id sent when the caller names none, already in the backend's form; empty when the endpoint names none.
|
|
62
|
+
DefaultModel string
|
|
63
|
+
// ModelsVerifyKey is always explicit after resolution.
|
|
64
|
+
ModelsVerifyKey bool
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func boolPtr(b bool) *bool { return &b }
|
|
68
|
+
|
|
69
|
+
// DecisionsBackends is the registry of known judgment backends.
|
|
70
|
+
var DecisionsBackends = map[string]BackendConfig{
|
|
71
|
+
BackendTypeSafe: {Label: "TypeSafe", Host: "https://api.typesafe.ai", KeyEnv: typesafeKeyEnv},
|
|
72
|
+
BackendOpenRouter: {
|
|
73
|
+
Label: "OpenRouter", Host: "https://openrouter.ai", KeyEnv: "OPENROUTER_API_KEY",
|
|
74
|
+
Path: "/api/alpha/decisions", ModelsPath: "/api/v1/models", ModelsField: "data", ModelsIDField: "id",
|
|
75
|
+
ModelsVerifyKey: boolPtr(false),
|
|
76
|
+
},
|
|
77
|
+
BackendCommandCode: {
|
|
78
|
+
Label: "Command Code", Host: "https://api.commandcode.ai", KeyEnv: "COMMANDCODE_API_KEY",
|
|
79
|
+
Path: "/provider/v1/systemone", ModelsPath: "/provider/v1/models", ModelsField: "data", ModelsIDField: "id",
|
|
80
|
+
ModelsVerifyKey: boolPtr(false),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ownModelConfig is the own-model backend: no host, no key, nothing leaves for a TypeSafe service.
|
|
85
|
+
var ownModelConfig = BackendConfig{Label: "Own model", Local: true}
|
|
86
|
+
|
|
87
|
+
func registryNames() string {
|
|
88
|
+
names := []string{BackendTypeSafe, BackendOpenRouter, BackendCommandCode, BackendOwnModel}
|
|
89
|
+
return strings.Join(names, ", ")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// defaultModels are each backend's default model id as the caller writes it, before mapping.
|
|
93
|
+
var defaultModels = map[string]string{
|
|
94
|
+
BackendTypeSafe: "jev-latest",
|
|
95
|
+
BackendOpenRouter: "typesafe/jev-1.13",
|
|
96
|
+
BackendCommandCode: "typesafe/jev",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var jevVersion = regexp.MustCompile(`^jev-(\d+)\.(\d+)(?:\.\d+)?$`)
|
|
100
|
+
|
|
101
|
+
// BackendModelID is the model id to send for a caller's model on this registry backend. OpenRouter routes a
|
|
102
|
+
// bare Jev id under the typesafe author: jev-latest becomes ~typesafe/jev-latest, and a bare
|
|
103
|
+
// jev-<major>.<minor> (with or without an optional .<patch>) becomes typesafe/jev-<major>.<minor>. An id that
|
|
104
|
+
// already carries an author, a bare id this rule does not know, and every model on a backend without a mapping
|
|
105
|
+
// pass through unchanged. Mapping an already mapped id changes nothing.
|
|
106
|
+
func BackendModelID(backend, model string) string {
|
|
107
|
+
if strings.Contains(model, "/") || backend != BackendOpenRouter {
|
|
108
|
+
return model
|
|
109
|
+
}
|
|
110
|
+
if model == "jev-latest" {
|
|
111
|
+
return "~typesafe/jev-latest"
|
|
112
|
+
}
|
|
113
|
+
if m := jevVersion.FindStringSubmatch(model); m != nil {
|
|
114
|
+
return "typesafe/jev-" + m[1] + "." + m[2]
|
|
115
|
+
}
|
|
116
|
+
return model
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// DefaultModelID is the model a client sends when the caller names none, in the form that backend accepts.
|
|
120
|
+
func DefaultModelID(backend string) string {
|
|
121
|
+
return BackendModelID(backend, defaultModels[backend])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// UsesTypeSafeKey says whether a backend's key comes from the TypeSafe resolution (TYPESAFE_API_KEY, then
|
|
125
|
+
// the login store) or only from its own environment variable.
|
|
126
|
+
func UsesTypeSafeKey(b BackendConfig) bool {
|
|
127
|
+
return !b.Local && (b.KeyEnv == "" || b.KeyEnv == typesafeKeyEnv)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const keyEnvMessage = "Backend keyEnv must name an environment variable: letters, digits, and underscores, not starting with a digit."
|
|
131
|
+
const hostMessage = "Backend host must be an absolute https: URL with no user info, path, query, or fragment (http: is allowed only for localhost, 127.0.0.0/8, and [::1])."
|
|
132
|
+
|
|
133
|
+
var (
|
|
134
|
+
keyEnvPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
135
|
+
loopbackV4 = regexp.MustCompile(`^127(?:\.\d{1,3}){3}$`)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
func refuse(message string) error { return newError(CodeConfiguration, message) }
|
|
139
|
+
|
|
140
|
+
func hasRawQueryOrFragment(v string) bool { return strings.ContainsAny(v, "?#") }
|
|
141
|
+
|
|
142
|
+
// utf16Len is the JavaScript string length of s.
|
|
143
|
+
func utf16Len(s string) int {
|
|
144
|
+
n := 0
|
|
145
|
+
for _, r := range s {
|
|
146
|
+
if r >= 0x10000 {
|
|
147
|
+
n += 2
|
|
148
|
+
} else {
|
|
149
|
+
n++
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return n
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// endpointFields converts a caller-supplied endpoint to the field map the validation reads, so a struct and a
|
|
156
|
+
// map (from a settings file, say) meet the same rules, including the type rules a struct cannot break.
|
|
157
|
+
func endpointFields(spec any) (map[string]any, bool) {
|
|
158
|
+
switch t := spec.(type) {
|
|
159
|
+
case map[string]any:
|
|
160
|
+
return t, true
|
|
161
|
+
case BackendEndpoint:
|
|
162
|
+
return endpointToMap(t), true
|
|
163
|
+
case *BackendEndpoint:
|
|
164
|
+
if t == nil {
|
|
165
|
+
return nil, false
|
|
166
|
+
}
|
|
167
|
+
return endpointToMap(*t), true
|
|
168
|
+
}
|
|
169
|
+
return nil, false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func endpointToMap(e BackendEndpoint) map[string]any {
|
|
173
|
+
m := map[string]any{"label": e.Label, "host": e.Host, "keyEnv": e.KeyEnv}
|
|
174
|
+
for k, v := range map[string]string{"path": e.Path, "modelsPath": e.ModelsPath, "modelsField": e.ModelsField, "modelsIdField": e.ModelsIDField, "defaultModel": e.DefaultModel} {
|
|
175
|
+
if v != "" {
|
|
176
|
+
m[k] = v
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if e.ModelsVerifyKey != nil {
|
|
180
|
+
m["modelsVerifyKey"] = *e.ModelsVerifyKey
|
|
181
|
+
}
|
|
182
|
+
return m
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ResolveBackend resolves a registry name or a caller-supplied endpoint (a BackendEndpoint, a pointer to
|
|
186
|
+
// one, or a map with the same fields) into the validated form the client uses. A name resolves to its
|
|
187
|
+
// registry entry unchanged; an endpoint is validated field by field and returned as a fresh value whose Host
|
|
188
|
+
// is origin only. Messages never quote a caller value (a host can carry credentials in its user info) except
|
|
189
|
+
// the label, and only after it is validated. Validation runs on every call; nothing is cached. nil resolves
|
|
190
|
+
// to DefaultBackend.
|
|
191
|
+
func ResolveBackend(backend any) (ResolvedBackend, error) {
|
|
192
|
+
if backend == nil {
|
|
193
|
+
backend = DefaultBackend
|
|
194
|
+
}
|
|
195
|
+
if name, ok := backend.(string); ok {
|
|
196
|
+
if name == BackendOwnModel {
|
|
197
|
+
return ResolvedBackend{BackendConfig: ownModelConfig, Name: name, ModelsVerifyKey: false}, nil
|
|
198
|
+
}
|
|
199
|
+
entry, ok := DecisionsBackends[name]
|
|
200
|
+
if !ok {
|
|
201
|
+
return ResolvedBackend{}, refuse(fmt.Sprintf("Unknown judgment backend %q. Valid backends: %s.", name, registryNames()))
|
|
202
|
+
}
|
|
203
|
+
if entry.KeyEnv == "" {
|
|
204
|
+
entry.KeyEnv = typesafeKeyEnv
|
|
205
|
+
}
|
|
206
|
+
return ResolvedBackend{BackendConfig: entry, Name: name, DefaultModel: DefaultModelID(name), ModelsVerifyKey: entry.ModelsVerifyKey == nil || *entry.ModelsVerifyKey}, nil
|
|
207
|
+
}
|
|
208
|
+
spec, ok := endpointFields(backend)
|
|
209
|
+
if !ok {
|
|
210
|
+
return ResolvedBackend{}, refuse("backend must be a registry name or a backend object.")
|
|
211
|
+
}
|
|
212
|
+
str := func(field string) (string, bool, bool) { // value, present, isString
|
|
213
|
+
v, present := spec[field]
|
|
214
|
+
if !present || v == nil {
|
|
215
|
+
return "", false, true
|
|
216
|
+
}
|
|
217
|
+
s, isStr := v.(string)
|
|
218
|
+
return s, true, isStr
|
|
219
|
+
}
|
|
220
|
+
label, _, isStr := str("label")
|
|
221
|
+
label = strings.TrimSpace(label)
|
|
222
|
+
if !isStr || label == "" || utf16Len(label) > 60 {
|
|
223
|
+
return ResolvedBackend{}, refuse("Backend label must be a nonempty string of at most 60 characters.")
|
|
224
|
+
}
|
|
225
|
+
hostText, _, isStr := str("host")
|
|
226
|
+
if !isStr || hasRawQueryOrFragment(hostText) {
|
|
227
|
+
return ResolvedBackend{}, refuse(hostMessage)
|
|
228
|
+
}
|
|
229
|
+
origin, err := validateHost(hostText)
|
|
230
|
+
if err != nil {
|
|
231
|
+
return ResolvedBackend{}, err
|
|
232
|
+
}
|
|
233
|
+
for _, f := range []struct{ field, message string }{
|
|
234
|
+
{"path", `Backend path must be a string that starts with "/".`},
|
|
235
|
+
{"modelsPath", `Backend modelsPath must be a string that starts with "/".`},
|
|
236
|
+
} {
|
|
237
|
+
v, present, isStr := str(f.field)
|
|
238
|
+
if present && (!isStr || !strings.HasPrefix(v, "/") || hasRawQueryOrFragment(v)) {
|
|
239
|
+
return ResolvedBackend{}, refuse(f.message)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for _, f := range []struct{ field, message string }{
|
|
243
|
+
{"modelsField", "Backend modelsField must be a nonempty string."},
|
|
244
|
+
{"modelsIdField", "Backend modelsIdField must be a nonempty string."},
|
|
245
|
+
} {
|
|
246
|
+
v, present, isStr := str(f.field)
|
|
247
|
+
if present && (!isStr || v == "") {
|
|
248
|
+
return ResolvedBackend{}, refuse(f.message)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
verify := false
|
|
252
|
+
if v, present := spec["modelsVerifyKey"]; present && v != nil {
|
|
253
|
+
b, isBool := v.(bool)
|
|
254
|
+
if !isBool {
|
|
255
|
+
return ResolvedBackend{}, refuse("Backend modelsVerifyKey must be a boolean.")
|
|
256
|
+
}
|
|
257
|
+
verify = b
|
|
258
|
+
}
|
|
259
|
+
keyEnv, _, isStr := str("keyEnv")
|
|
260
|
+
if !isStr || !keyEnvPattern.MatchString(keyEnv) {
|
|
261
|
+
return ResolvedBackend{}, refuse(keyEnvMessage)
|
|
262
|
+
}
|
|
263
|
+
if strings.EqualFold(keyEnv, typesafeKeyEnv) {
|
|
264
|
+
return ResolvedBackend{}, refuse("Backend keyEnv must not be TYPESAFE_API_KEY: the TypeSafe key is only sent to the typesafe backend. Give this endpoint its own variable.")
|
|
265
|
+
}
|
|
266
|
+
defaultModel, present, isStr := str("defaultModel")
|
|
267
|
+
if present && (!isStr || strings.TrimSpace(defaultModel) == "" || utf16Len(defaultModel) > 100) {
|
|
268
|
+
return ResolvedBackend{}, refuse("Backend defaultModel must be a nonempty string of at most 100 characters.")
|
|
269
|
+
}
|
|
270
|
+
out := ResolvedBackend{Name: "", DefaultModel: defaultModel, ModelsVerifyKey: verify}
|
|
271
|
+
out.Label, out.Host, out.KeyEnv = label, origin, keyEnv
|
|
272
|
+
out.Path, _, _ = str("path")
|
|
273
|
+
out.ModelsPath, _, _ = str("modelsPath")
|
|
274
|
+
out.ModelsField, _, _ = str("modelsField")
|
|
275
|
+
out.ModelsIDField, _, _ = str("modelsIdField")
|
|
276
|
+
out.ModelsVerifyKey = verify
|
|
277
|
+
return out, nil
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// validateHost applies the host rule and returns the origin (scheme, host and port).
|
|
281
|
+
func validateHost(text string) (string, error) {
|
|
282
|
+
u, err := url.Parse(text)
|
|
283
|
+
if err != nil || u.Scheme == "" || u.Host == "" || u.Opaque != "" {
|
|
284
|
+
return "", refuse(hostMessage)
|
|
285
|
+
}
|
|
286
|
+
scheme := strings.ToLower(u.Scheme)
|
|
287
|
+
hostname := strings.ToLower(u.Hostname())
|
|
288
|
+
// A dotted form with an octet above 255 is not an address (the original's URL parser rejects it); Go would
|
|
289
|
+
// resolve it as a name, so the 127.0.0.0/8 rule also needs it to parse as an IP.
|
|
290
|
+
loopback := hostname == "localhost" || hostname == "::1" || (loopbackV4.MatchString(hostname) && net.ParseIP(hostname) != nil)
|
|
291
|
+
if scheme != "https" && !(scheme == "http" && loopback) {
|
|
292
|
+
return "", refuse(hostMessage)
|
|
293
|
+
}
|
|
294
|
+
if u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
|
295
|
+
return "", refuse(hostMessage)
|
|
296
|
+
}
|
|
297
|
+
port := u.Port()
|
|
298
|
+
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
|
299
|
+
port = ""
|
|
300
|
+
}
|
|
301
|
+
host := hostname
|
|
302
|
+
if strings.Contains(hostname, ":") {
|
|
303
|
+
host = "[" + hostname + "]"
|
|
304
|
+
}
|
|
305
|
+
if port != "" {
|
|
306
|
+
host += ":" + port
|
|
307
|
+
}
|
|
308
|
+
return scheme + "://" + host, nil
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// BackendHost is the destination host only (host and port, no scheme), for example api.commandcode.ai, for
|
|
312
|
+
// consent text. The own-model backend has none and returns "".
|
|
313
|
+
func BackendHost(backend any) (string, error) {
|
|
314
|
+
resolved, err := ResolveBackend(backend)
|
|
315
|
+
if err != nil {
|
|
316
|
+
return "", err
|
|
317
|
+
}
|
|
318
|
+
if resolved.Host == "" {
|
|
319
|
+
return "", nil
|
|
320
|
+
}
|
|
321
|
+
u, err := url.Parse(resolved.Host)
|
|
322
|
+
if err != nil {
|
|
323
|
+
return "", err
|
|
324
|
+
}
|
|
325
|
+
return u.Host, nil
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// BackendNames lists the registry names, sorted, for help text.
|
|
329
|
+
func BackendNames() []string {
|
|
330
|
+
names := []string{BackendOwnModel}
|
|
331
|
+
for n := range DecisionsBackends {
|
|
332
|
+
names = append(names, n)
|
|
333
|
+
}
|
|
334
|
+
sort.Strings(names)
|
|
335
|
+
return names
|
|
336
|
+
}
|