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