@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,404 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "strings"
7
+ "sync/atomic"
8
+ "testing"
9
+ )
10
+
11
+ const (
12
+ ccKey = "cc_fake_key_0123456789abcdef"
13
+ gwKey = "gw_fake_key_0123456789abcdef"
14
+ tsKey = "ts_fake_key_0123456789abcdef"
15
+ storedKey = "st_fake_key_0123456789abcdef"
16
+ )
17
+
18
+ func gatewayEndpoint() BackendEndpoint {
19
+ return BackendEndpoint{BackendConfig: BackendConfig{Label: "Gateway", Host: "https://gw.example.com", Path: "/jev/v1/systemone", KeyEnv: "GATEWAY_JEV_KEY"}, DefaultModel: "jev-latest"}
20
+ }
21
+
22
+ // endpointMap is a caller-supplied endpoint as a settings file would hold it, so a value of the wrong type is expressible.
23
+ func endpointMap(overrides map[string]any) map[string]any {
24
+ m := map[string]any{"label": "Gateway", "host": "https://gw.example.com", "keyEnv": "GATEWAY_JEV_KEY", "defaultModel": "jev-latest"}
25
+ for k, v := range overrides {
26
+ if v == deleted {
27
+ delete(m, k)
28
+ } else {
29
+ m[k] = v
30
+ }
31
+ }
32
+ return m
33
+ }
34
+
35
+ const deleted = "\x00delete"
36
+
37
+ type refusalOptions struct{ apiKey, model string }
38
+
39
+ func assertRefuses(t *testing.T, backend any, message string, o refusalOptions) {
40
+ t.Helper()
41
+ var calls atomic.Int32
42
+ _, err := New(Options{Backend: backend, APIKey: o.apiKey, Model: o.model, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
43
+ calls.Add(1)
44
+ return rawResponse(200, "{}"), nil
45
+ })})
46
+ if !hasCode(err, CodeConfiguration) || err.Error() != message || strings.Contains(err.Error(), "user:pw") {
47
+ t.Errorf("backend %v: err = %v, want configuration %q", backend, err, message)
48
+ }
49
+ if calls.Load() != 0 {
50
+ t.Errorf("a refused backend sent %d requests", calls.Load())
51
+ }
52
+ }
53
+
54
+ func TestBackends(t *testing.T) {
55
+ tw(t, "backends", "a commandcode request goes to the Command Code path with its own key and model", func(t *testing.T) {
56
+ isolate(t)
57
+ t.Setenv("COMMANDCODE_API_KEY", ccKey)
58
+ calls := 0
59
+ client, err := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
60
+ calls++
61
+ if r.URL.String() != "https://api.commandcode.ai/provider/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+ccKey {
62
+ t.Errorf("url=%s auth=%s", r.URL, r.Header.Get("Authorization"))
63
+ }
64
+ body := requestBody(t, r)
65
+ if !strings.Contains(string(body), `"model":"typesafe/jev"`) {
66
+ t.Errorf("body = %s", body)
67
+ }
68
+ return responseFor(body), nil
69
+ })})
70
+ if err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
74
+ t.Fatalf("err = %v calls=%d", err, calls)
75
+ }
76
+ })
77
+ tw(t, "backends", "a per-request model on commandcode is sent unchanged", func(t *testing.T) {
78
+ isolate(t)
79
+ t.Setenv("COMMANDCODE_API_KEY", ccKey)
80
+ client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
81
+ body := requestBody(t, r)
82
+ if !strings.Contains(string(body), `"model":"jev-2.0"`) {
83
+ t.Errorf("body = %s", body)
84
+ }
85
+ return responseFor(body), nil
86
+ })})
87
+ req := sampleRequest()
88
+ req.Model = "jev-2.0"
89
+ if _, err := client.Evaluate(context.Background(), req); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ })
93
+ tw(t, "backends", "a caller-supplied endpoint sends to its own path with its own key and unmapped model", func(t *testing.T) {
94
+ isolate(t)
95
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
96
+ calls := 0
97
+ client, err := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
98
+ calls++
99
+ body := requestBody(t, r)
100
+ if r.URL.String() != "https://gw.example.com/jev/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+gwKey || !strings.Contains(string(body), `"model":"jev-latest"`) {
101
+ t.Errorf("url=%s auth=%s body=%s", r.URL, r.Header.Get("Authorization"), body)
102
+ }
103
+ return responseFor(body), nil
104
+ })})
105
+ if err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
109
+ t.Fatalf("err = %v calls=%d", err, calls)
110
+ }
111
+ })
112
+ tw(t, "backends", "an endpoint without a path uses the SDK's own /v1/systemone", func(t *testing.T) {
113
+ isolate(t)
114
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
115
+ endpoint := gatewayEndpoint()
116
+ endpoint.Path = ""
117
+ var url string
118
+ client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
119
+ url = r.URL.String()
120
+ return responseFor(requestBody(t, r)), nil
121
+ })})
122
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || url != "https://gw.example.com/v1/systemone" {
123
+ t.Fatalf("err = %v url=%s", err, url)
124
+ }
125
+ })
126
+ tw(t, "backends", "an endpoint may use a loopback http: host", func(t *testing.T) {
127
+ isolate(t)
128
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
129
+ endpoint := BackendEndpoint{BackendConfig: BackendConfig{Label: "Local", Host: "http://127.0.0.1:8787", KeyEnv: "GATEWAY_JEV_KEY"}, DefaultModel: "jev-latest"}
130
+ var url string
131
+ client, err := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
132
+ url = r.URL.String()
133
+ return responseFor(requestBody(t, r)), nil
134
+ })})
135
+ if err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || url != "http://127.0.0.1:8787/v1/systemone" {
139
+ t.Fatalf("err = %v url=%s", err, url)
140
+ }
141
+ })
142
+ tw(t, "backends", "an endpoint label must be a nonempty string of at most 60 characters", func(t *testing.T) {
143
+ for _, label := range []any{7, "", " ", strings.Repeat("x", 61)} {
144
+ assertRefuses(t, endpointMap(map[string]any{"label": label}), "Backend label must be a nonempty string of at most 60 characters.", refusalOptions{})
145
+ }
146
+ })
147
+ tw(t, "backends", "an endpoint host must be an absolute https: URL with no user info, path, query, or fragment", func(t *testing.T) {
148
+ for _, host := range []string{"http://gw.example.com", "https://user:pw@gw.example.com", "https://gw.example.com/prefix", "https://gw.example.com?x=1", "https://gw.example.com#f", "ftp://gw.example.com", "not a url"} {
149
+ assertRefuses(t, endpointMap(map[string]any{"host": host}), hostMessage, refusalOptions{})
150
+ }
151
+ })
152
+ tw(t, "backends", `an endpoint path must be a string that starts with "/"`, func(t *testing.T) {
153
+ for _, path := range []any{"jev", "x?q", "x#f", 7} {
154
+ assertRefuses(t, endpointMap(map[string]any{"path": path}), `Backend path must be a string that starts with "/".`, refusalOptions{})
155
+ }
156
+ })
157
+ tw(t, "backends", `an endpoint modelsPath must be a string that starts with "/"`, func(t *testing.T) {
158
+ for _, p := range []any{"models", "x?q", 7} {
159
+ assertRefuses(t, endpointMap(map[string]any{"modelsPath": p}), `Backend modelsPath must be a string that starts with "/".`, refusalOptions{})
160
+ }
161
+ })
162
+ tw(t, "backends", "an endpoint modelsField must be a nonempty string", func(t *testing.T) {
163
+ for _, v := range []any{"", 7} {
164
+ assertRefuses(t, endpointMap(map[string]any{"modelsField": v}), "Backend modelsField must be a nonempty string.", refusalOptions{})
165
+ }
166
+ })
167
+ tw(t, "backends", "an endpoint modelsIdField must be a nonempty string", func(t *testing.T) {
168
+ for _, v := range []any{"", 7} {
169
+ assertRefuses(t, endpointMap(map[string]any{"modelsIdField": v}), "Backend modelsIdField must be a nonempty string.", refusalOptions{})
170
+ }
171
+ })
172
+ tw(t, "backends", "an endpoint modelsVerifyKey must be a boolean", func(t *testing.T) {
173
+ for _, v := range []any{"yes", 1} {
174
+ assertRefuses(t, endpointMap(map[string]any{"modelsVerifyKey": v}), "Backend modelsVerifyKey must be a boolean.", refusalOptions{})
175
+ }
176
+ })
177
+ tw(t, "backends", "an endpoint keyEnv must name an environment variable", func(t *testing.T) {
178
+ for _, v := range []string{"1BAD", "GATEWAY KEY", ""} {
179
+ assertRefuses(t, endpointMap(map[string]any{"keyEnv": v, "path": deleted}), keyEnvMessage, refusalOptions{})
180
+ }
181
+ })
182
+ tw(t, "backends", "an endpoint keyEnv must not be TYPESAFE_API_KEY", func(t *testing.T) {
183
+ message := "Backend keyEnv must not be TYPESAFE_API_KEY: the TypeSafe key is only sent to the typesafe backend. Give this endpoint its own variable."
184
+ for _, v := range []string{"TYPESAFE_API_KEY", "typesafe_api_key"} {
185
+ assertRefuses(t, endpointMap(map[string]any{"keyEnv": v}), message, refusalOptions{})
186
+ }
187
+ })
188
+ tw(t, "backends", "an endpoint defaultModel must be a nonempty string of at most 100 characters", func(t *testing.T) {
189
+ for _, v := range []any{"", " ", strings.Repeat("x", 101), 7} {
190
+ assertRefuses(t, endpointMap(map[string]any{"defaultModel": v}), "Backend defaultModel must be a nonempty string of at most 100 characters.", refusalOptions{})
191
+ }
192
+ })
193
+ tw(t, "backends", "an endpoint with no defaultModel needs model on createTypeSafe", func(t *testing.T) {
194
+ assertRefuses(t, endpointMap(map[string]any{"defaultModel": deleted}), `Backend "Gateway" names no defaultModel; pass Model to New.`, refusalOptions{apiKey: "fake_key_0123456789abcdef"})
195
+ })
196
+ tw(t, "backends", "backend must be a registry name or a backend object", func(t *testing.T) {
197
+ // nil is the default backend in Go, so the original's null case has no equivalent; 42 and true remain.
198
+ for _, backend := range []any{42, true} {
199
+ assertRefuses(t, backend, "backend must be a registry name or a backend object.", refusalOptions{})
200
+ }
201
+ // An unknown name keeps the registry's own error. It lists the own-model backend this port adds.
202
+ assertRefuses(t, "unknown-backend", `Unknown judgment backend "unknown-backend". Valid backends: typesafe, openrouter, commandcode, ownmodel.`, refusalOptions{})
203
+ })
204
+ tw(t, "backends", "authState refuses an invalid backend instead of reporting a status", func(t *testing.T) {
205
+ isolate(t)
206
+ _, err := GetAuthState(AuthOptions{Backend: map[string]any{"label": "x", "host": "http://evil.example", "keyEnv": "K"}})
207
+ if !hasCode(err, CodeConfiguration) || err.Error() != hostMessage {
208
+ t.Fatalf("err = %v", err)
209
+ }
210
+ })
211
+ tw(t, "backends", "an endpoint never reads TYPESAFE_API_KEY or the login store", func(t *testing.T) {
212
+ isolate(t)
213
+ t.Setenv("TYPESAFE_API_KEY", tsKey)
214
+ _, _ = StoreAPIKey(storedKey)
215
+ endpoint := gatewayEndpoint()
216
+ endpoint.Path = ""
217
+ var calls atomic.Int32
218
+ _, err := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { calls.Add(1); return rawResponse(200, "{}"), nil })})
219
+ if !hasCode(err, CodeConfiguration) || err.Error() != "No API key. Set GATEWAY_JEV_KEY in the environment." || calls.Load() != 0 {
220
+ t.Fatalf("err = %v", err)
221
+ }
222
+ if s, _ := KeySituationFor(gatewayEndpoint()); s.Kind != KeyMissing {
223
+ t.Errorf("situation = %+v", s)
224
+ }
225
+ if mustAuth(t, gatewayEndpoint()).Usable {
226
+ t.Error("an endpoint without its own key is unusable")
227
+ }
228
+ })
229
+ tw(t, "backends", "commandcode with no COMMANDCODE_API_KEY is missing and unusable", func(t *testing.T) {
230
+ isolate(t)
231
+ t.Setenv("TYPESAFE_API_KEY", tsKey)
232
+ _, _ = StoreAPIKey(storedKey)
233
+ _, err := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { t.Error("no request may be sent"); return nil, nil })})
234
+ if !hasCode(err, CodeConfiguration) || err.Error() != "No API key. Set COMMANDCODE_API_KEY in the environment." {
235
+ t.Fatalf("err = %v", err)
236
+ }
237
+ if s, _ := KeySituationFor("commandcode"); s.Kind != KeyMissing || mustAuth(t, "commandcode").Usable {
238
+ t.Errorf("situation = %+v", s)
239
+ }
240
+ })
241
+ tw(t, "backends", "a request to an endpoint carries its own key, never the TypeSafe key", func(t *testing.T) {
242
+ isolate(t)
243
+ t.Setenv("TYPESAFE_API_KEY", tsKey)
244
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
245
+ _, _ = StoreAPIKey(storedKey)
246
+ calls := 0
247
+ client, err := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
248
+ calls++
249
+ for name, values := range r.Header {
250
+ for _, v := range values {
251
+ if strings.Contains(v, tsKey) || strings.Contains(v, storedKey) {
252
+ t.Errorf("header %s carries another backend's key", name)
253
+ }
254
+ }
255
+ }
256
+ if r.Header.Get("Authorization") != "Bearer "+gwKey {
257
+ t.Errorf("authorization = %s", r.Header.Get("Authorization"))
258
+ }
259
+ return responseFor(requestBody(t, r)), nil
260
+ })})
261
+ if err != nil {
262
+ t.Fatal(err)
263
+ }
264
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
265
+ t.Fatalf("err = %v", err)
266
+ }
267
+ })
268
+ tw(t, "backends", "a public model list on commandcode leaves the auth state unverified", func(t *testing.T) {
269
+ isolate(t)
270
+ t.Setenv("COMMANDCODE_API_KEY", ccKey)
271
+ client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
272
+ return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "typesafe/jev"}}}, nil), nil
273
+ })})
274
+ names, err := client.ListModels(context.Background())
275
+ if err != nil || len(names) != 1 || names[0] != "typesafe/jev" {
276
+ t.Fatalf("models = %v, %v", names, err)
277
+ }
278
+ if s := mustAuth(t, "commandcode"); s.Verified || s.VerifiedAt != "" {
279
+ t.Fatalf("state = %+v", s)
280
+ }
281
+ })
282
+ tw(t, "backends", "a model list on an endpoint without modelsVerifyKey leaves the auth state unverified", func(t *testing.T) {
283
+ isolate(t)
284
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
285
+ endpoint := gatewayEndpoint()
286
+ endpoint.Path = ""
287
+ client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
288
+ return jsonResponse(200, map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, nil), nil
289
+ })})
290
+ names, err := client.ListModels(context.Background())
291
+ if err != nil || len(names) != 1 || names[0] != "jev-latest" {
292
+ t.Fatalf("models = %v, %v", names, err)
293
+ }
294
+ if s := mustAuth(t, gatewayEndpoint()); s.Verified || s.VerifiedAt != "" {
295
+ t.Fatalf("state = %+v", s)
296
+ }
297
+ })
298
+ tw(t, "backends", "an endpoint with modelsVerifyKey true records verification", func(t *testing.T) {
299
+ isolate(t)
300
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
301
+ endpoint := BackendEndpoint{BackendConfig: BackendConfig{Label: "Gateway", Host: "https://gw.example.com", KeyEnv: "GATEWAY_JEV_KEY", ModelsVerifyKey: boolPtr(true)}, DefaultModel: "jev-latest"}
302
+ client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
303
+ return jsonResponse(200, map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, nil), nil
304
+ })})
305
+ if _, err := client.ListModels(context.Background()); err != nil {
306
+ t.Fatal(err)
307
+ }
308
+ if s := mustAuth(t, endpoint); !s.Verified || s.VerifiedAt == "" {
309
+ t.Fatalf("state = %+v", s)
310
+ }
311
+ })
312
+ tw(t, "backends", "a successful evaluate records verification even after a public model list", func(t *testing.T) {
313
+ isolate(t)
314
+ t.Setenv("COMMANDCODE_API_KEY", ccKey)
315
+ client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
316
+ if strings.HasSuffix(r.URL.Path, "/models") {
317
+ return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "typesafe/jev"}}}, nil), nil
318
+ }
319
+ return responseFor(requestBody(t, r)), nil
320
+ })})
321
+ _, _ = client.ListModels(context.Background())
322
+ if mustAuth(t, "commandcode").Verified {
323
+ t.Fatal("a public list must not verify")
324
+ }
325
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ if !mustAuth(t, "commandcode").Verified {
329
+ t.Fatal("a successful evaluate must verify")
330
+ }
331
+ })
332
+ tw(t, "backends", "a malformed reply on commandcode is a response error", func(t *testing.T) {
333
+ isolate(t)
334
+ t.Setenv("COMMANDCODE_API_KEY", ccKey)
335
+ client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
336
+ return jsonResponse(200, map[string]any{"model": "jev-test", "answers": map[string]any{}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}}, nil), nil
337
+ })})
338
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
339
+ t.Fatalf("err = %v", err)
340
+ }
341
+ })
342
+ tw(t, "backends", "a malformed reply on an endpoint is a response error", func(t *testing.T) {
343
+ isolate(t)
344
+ t.Setenv("GATEWAY_JEV_KEY", gwKey)
345
+ client, _ := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
346
+ return jsonResponse(200, map[string]any{"model": "jev-test", "answers": map[string]any{"yes": map[string]any{"type": "noul", "noul": 2}}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}}, nil), nil
347
+ })})
348
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
349
+ t.Fatalf("err = %v", err)
350
+ }
351
+ })
352
+ tw(t, "backends", "401 advice names each backend's own key variable", func(t *testing.T) {
353
+ if got := SafeError(apiError(401), "commandcode").Message; got != "TypeSafe returned HTTP 401. Check COMMANDCODE_API_KEY. No automatic retry was made." {
354
+ t.Errorf("commandcode: %s", got)
355
+ }
356
+ if got := SafeError(apiError(401), gatewayEndpoint()).Message; got != "TypeSafe returned HTTP 401. Check GATEWAY_JEV_KEY. No automatic retry was made." {
357
+ t.Errorf("gateway: %s", got)
358
+ }
359
+ })
360
+ tw(t, "backends", "resolveBackend resolves registry names to the registry entries", func(t *testing.T) {
361
+ ts, _ := ResolveBackend("typesafe")
362
+ if ts.Name != "typesafe" || ts.Label != "TypeSafe" || ts.Host != "https://api.typesafe.ai" || ts.KeyEnv != "TYPESAFE_API_KEY" || ts.DefaultModel != "jev-latest" || !ts.ModelsVerifyKey || ts.Path != "" {
363
+ t.Errorf("typesafe = %+v", ts)
364
+ }
365
+ cc, _ := ResolveBackend("commandcode")
366
+ if cc.Name != "commandcode" || cc.Label != "Command Code" || cc.Host != "https://api.commandcode.ai" || cc.KeyEnv != "COMMANDCODE_API_KEY" || cc.Path != "/provider/v1/systemone" ||
367
+ cc.ModelsPath != "/provider/v1/models" || cc.ModelsField != "data" || cc.ModelsIDField != "id" || cc.DefaultModel != "typesafe/jev" || cc.ModelsVerifyKey {
368
+ t.Errorf("commandcode = %+v", cc)
369
+ }
370
+ if or, _ := ResolveBackend("openrouter"); or.DefaultModel != "typesafe/jev-1.13" {
371
+ t.Errorf("openrouter = %+v", or)
372
+ }
373
+ })
374
+ tw(t, "backends", "backendHost reports the destination host", func(t *testing.T) {
375
+ cases := map[string]any{
376
+ "api.commandcode.ai": "commandcode", "api.typesafe.ai": "typesafe", "openrouter.ai": "openrouter", "gw.example.com": gatewayEndpoint(),
377
+ "gw.example.com:8443": endpointMap(map[string]any{"host": "https://gw.example.com:8443"}),
378
+ "127.0.0.1:8787": endpointMap(map[string]any{"host": "http://127.0.0.1:8787"}),
379
+ }
380
+ for want, backend := range cases {
381
+ if got, err := BackendHost(backend); err != nil || got != want {
382
+ t.Errorf("BackendHost(%v) = %q, %v; want %q", backend, got, err, want)
383
+ }
384
+ }
385
+ })
386
+ }
387
+
388
+ func TestOwnModelBackendResolves(t *testing.T) {
389
+ b, err := ResolveBackend("ownmodel")
390
+ if err != nil || !b.Local || b.Host != "" || b.Label == "" {
391
+ t.Fatalf("ownmodel = %+v, %v", b, err)
392
+ }
393
+ if host, err := BackendHost("ownmodel"); err != nil || host != "" {
394
+ t.Errorf("host = %q, %v", host, err)
395
+ }
396
+ isolate(t)
397
+ s, err := GetAuthState(AuthOptions{Backend: "ownmodel"})
398
+ if err != nil || s.Kind != KeyNotRequired || !s.Usable {
399
+ t.Fatalf("auth = %+v, %v", s, err)
400
+ }
401
+ if r, _ := DescribeAuth(s); r.Level != LevelOK || !strings.Contains(r.Text, "model PiG is configured with") {
402
+ t.Errorf("report = %+v", r)
403
+ }
404
+ }
@@ -0,0 +1,202 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "sync"
6
+ "time"
7
+
8
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
9
+ )
10
+
11
+ // DefaultConcurrency is the default number of requests in flight. TypeSafe answers in isolation, so a small pool is enough.
12
+ const DefaultConcurrency = 4
13
+
14
+ // Settled is one item's outcome. Skipped marks work that was never started because of a cancellation or a stop rule.
15
+ type Settled[T any] struct {
16
+ OK bool
17
+ Index int
18
+ Value T
19
+ Err error
20
+ Skipped bool
21
+ }
22
+
23
+ // FanOutOptions configure FanOut.
24
+ type FanOutOptions struct {
25
+ // Concurrency is the number of requests in flight at once. Default: DefaultConcurrency.
26
+ Concurrency int
27
+ // StopOn stops launching new work once it returns true for a failure, for example a budget error.
28
+ StopOn func(error) bool
29
+ }
30
+
31
+ // FanOut runs worker over items with bounded concurrency, preserving input order. It never fails: every item
32
+ // comes back as a settled result. A cancelled ctx stops starting new work; in-flight work still finishes.
33
+ // This is the pool the batching methods use, exported so script authors stop hand-rolling one.
34
+ func FanOut[I, O any](ctx context.Context, items []I, worker func(ctx context.Context, item I, index int) (O, error), opts FanOutOptions) []Settled[O] {
35
+ concurrency := opts.Concurrency
36
+ if concurrency < 1 {
37
+ concurrency = DefaultConcurrency
38
+ }
39
+ results := make([]*Settled[O], len(items))
40
+ var mu sync.Mutex
41
+ next := 0
42
+ stopped := false
43
+ run := func() {
44
+ for {
45
+ mu.Lock()
46
+ if stopped {
47
+ mu.Unlock()
48
+ return
49
+ }
50
+ index := next
51
+ next++
52
+ if index >= len(items) {
53
+ mu.Unlock()
54
+ return
55
+ }
56
+ if ctx.Err() != nil {
57
+ stopped = true
58
+ mu.Unlock()
59
+ return
60
+ }
61
+ mu.Unlock()
62
+ value, err := worker(ctx, items[index], index)
63
+ mu.Lock()
64
+ if err != nil {
65
+ results[index] = &Settled[O]{Index: index, Err: err}
66
+ if opts.StopOn != nil && opts.StopOn(err) {
67
+ stopped = true
68
+ }
69
+ } else {
70
+ results[index] = &Settled[O]{OK: true, Index: index, Value: value}
71
+ }
72
+ mu.Unlock()
73
+ }
74
+ }
75
+ var wg sync.WaitGroup
76
+ for range min(concurrency, len(items)) {
77
+ wg.Add(1)
78
+ go func() { defer wg.Done(); run() }()
79
+ }
80
+ wg.Wait()
81
+ reason := "TypeSafe batch stopped after a failed request; this request was not submitted."
82
+ if ctx.Err() != nil {
83
+ reason = "TypeSafe batch cancelled before this request was submitted."
84
+ }
85
+ out := make([]Settled[O], len(items))
86
+ for i := range items {
87
+ if results[i] != nil {
88
+ out[i] = *results[i]
89
+ } else {
90
+ out[i] = Settled[O]{Index: i, Err: newError(CodeAborted, reason), Skipped: true}
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ // BatchOptions configure EvaluateMany and EvaluateAll.
97
+ type BatchOptions struct {
98
+ // Concurrency is the number of requests in flight at once. Default: DefaultConcurrency.
99
+ Concurrency int
100
+ // MaxQuestions is the chunk size for EvaluateAll. Default: DefaultMaxQuestions.
101
+ MaxQuestions int
102
+ }
103
+
104
+ // BatchEvaluation holds per-request outcomes plus the merged view callers usually want.
105
+ type BatchEvaluation struct {
106
+ // OK is true when every request succeeded.
107
+ OK bool
108
+ // Results are per-request outcomes in input order.
109
+ Results []Settled[*Evaluation]
110
+ // Failures counts failures, including requests that were never submitted.
111
+ Failures int
112
+ // Skipped counts work never started, because of a cancellation or a budget stop.
113
+ Skipped int
114
+ // Answers are merged in input order; a repeated question id keeps the last answer. Empty when nothing succeeded.
115
+ Answers map[string]typesafe.Answer
116
+ // AnswerOrder lists the answer ids in the order they were first merged.
117
+ AnswerOrder []string
118
+ // Model is the model of the first successful request, when there is one.
119
+ Model string
120
+ // Usage is summed over the requests that succeeded.
121
+ Usage typesafe.Usage
122
+ // Elapsed is the wall-clock time for the whole batch.
123
+ ElapsedMs int64
124
+ }
125
+
126
+ func summarize(results []Settled[*Evaluation], elapsed time.Duration) *BatchEvaluation {
127
+ out := &BatchEvaluation{Results: results, Answers: map[string]typesafe.Answer{}, ElapsedMs: elapsed.Milliseconds()}
128
+ succeeded := 0
129
+ for _, r := range results {
130
+ if !r.OK {
131
+ if r.Skipped {
132
+ out.Skipped++
133
+ }
134
+ continue
135
+ }
136
+ succeeded++
137
+ for _, id := range r.Value.Order {
138
+ if _, seen := out.Answers[id]; !seen {
139
+ out.AnswerOrder = append(out.AnswerOrder, id)
140
+ }
141
+ out.Answers[id] = r.Value.Answers[id]
142
+ }
143
+ out.Usage.InputTokens += r.Value.Usage.InputTokens
144
+ out.Usage.OutputTokens += r.Value.Usage.OutputTokens
145
+ if out.Model == "" {
146
+ out.Model = r.Value.Model
147
+ }
148
+ }
149
+ out.OK = succeeded == len(results)
150
+ out.Failures = len(results) - succeeded
151
+ return out
152
+ }
153
+
154
+ // stopsBatch is a failure worth stopping the batch for: no more requests will be accepted, or the caller cancelled.
155
+ func stopsBatch(err error) bool {
156
+ ie, ok := err.(*IntegrationError)
157
+ return ok && (ie.Code == CodeBudget || ie.Code == CodeAborted)
158
+ }
159
+
160
+ // Evaluator is the part of a TypeSafe client the batching functions use.
161
+ type Evaluator interface {
162
+ Evaluate(ctx context.Context, request typesafe.SystemOneRequest) (*Evaluation, error)
163
+ }
164
+
165
+ // EvaluateMany sends several requests with bounded concurrency, in input order, and merges what came back.
166
+ // Each request passes through the same admission as Evaluate, so an invalid request is one settled failure, not
167
+ // an error. A budget or cancellation failure stops the rest from being submitted. It never fails.
168
+ func EvaluateMany(ctx context.Context, client Evaluator, requests []typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
169
+ start := time.Now()
170
+ results := FanOut(ctx, requests, func(ctx context.Context, request typesafe.SystemOneRequest, _ int) (*Evaluation, error) {
171
+ return client.Evaluate(ctx, request)
172
+ }, FanOutOptions{Concurrency: opts.Concurrency, StopOn: stopsBatch})
173
+ return summarize(results, time.Since(start))
174
+ }
175
+
176
+ // ChunkRequest splits a request that asks more questions than one request may carry into chunks of at most
177
+ // maxQuestions (default DefaultMaxQuestions). Sharing one state across several questions is one request; asking
178
+ // more than the per-request limit is the only reason to fan out, and the state is repeated in each chunk. The
179
+ // order of questions is preserved. A pure splitter: admission still happens once per chunk, in Evaluate.
180
+ func ChunkRequest(request typesafe.SystemOneRequest, maxQuestions int) []typesafe.SystemOneRequest {
181
+ limit := maxQuestions
182
+ if limit < 1 {
183
+ limit = DefaultMaxQuestions
184
+ }
185
+ if len(request.Questions) <= limit {
186
+ return []typesafe.SystemOneRequest{request}
187
+ }
188
+ var chunks []typesafe.SystemOneRequest
189
+ for i := 0; i < len(request.Questions); i += limit {
190
+ chunk := request
191
+ chunk.Questions = append(typesafe.Questions(nil), request.Questions[i:min(i+limit, len(request.Questions))]...)
192
+ chunks = append(chunks, chunk)
193
+ }
194
+ return chunks
195
+ }
196
+
197
+ // EvaluateAll asks any number of questions about one state: chunk to the per-request limit, fan out, and merge
198
+ // the answers, usage, and model. Use it when one coherent state carries many independent questions; use Evaluate
199
+ // for one request.
200
+ func EvaluateAll(ctx context.Context, client Evaluator, request typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
201
+ return EvaluateMany(ctx, client, ChunkRequest(request, opts.MaxQuestions), opts)
202
+ }