@pi-in-go/pigpen-jev 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 (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. package/provenance.json +18 -0
@@ -0,0 +1,464 @@
1
+ package typesafe_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "net"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "reflect"
13
+ "regexp"
14
+ "sort"
15
+ "strings"
16
+ "sync"
17
+ "testing"
18
+ "time"
19
+
20
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
21
+ )
22
+
23
+ // Cross-check against the official SDK. port/crosscheck/record_js.mjs runs every scenario in
24
+ // scenarios.json through @typesafe-ai/sdk 0.6.0 (built from the pinned commit) against a
25
+ // scripted local server and records the requests the server saw and the outcome. This test
26
+ // replays the same scenarios through the Go client against an equivalent scripted server
27
+ // and compares request shape (method, path, headers, body) and outcome (result or error
28
+ // class, message, status, request ID, body).
29
+
30
+ type step struct {
31
+ Status int `json:"status"`
32
+ Headers map[string]string `json:"headers"`
33
+ Body json.RawMessage `json:"body"`
34
+ BodyText *string `json:"bodyText"`
35
+ DelayMs int `json:"delayMs"`
36
+ Action string `json:"action"`
37
+ }
38
+
39
+ type scenario struct {
40
+ Name string `json:"name"`
41
+ Note string `json:"note"`
42
+ Config struct {
43
+ Retry map[string]any `json:"retry"`
44
+ TimeoutMs int `json:"timeoutMs"`
45
+ DefaultHdrs map[string]string `json:"defaultHeaders"`
46
+ BaseURLSuffix string `json:"baseURLSuffix"`
47
+ } `json:"config"`
48
+ Call struct {
49
+ Kind string `json:"kind"`
50
+ Request json.RawMessage `json:"request"`
51
+ Options struct {
52
+ Headers map[string]string `json:"headers"`
53
+ Retry map[string]any `json:"retry"`
54
+ TimeoutMs int `json:"timeoutMs"`
55
+ } `json:"options"`
56
+ } `json:"call"`
57
+ Responses []step `json:"responses"`
58
+ }
59
+
60
+ type seenRequest struct {
61
+ Method string `json:"method"`
62
+ Path string `json:"path"`
63
+ Headers map[string]string `json:"headers"`
64
+ Body string `json:"body"`
65
+ }
66
+
67
+ type outcome struct {
68
+ OK bool `json:"ok"`
69
+ Result json.RawMessage `json:"result"`
70
+ Class string `json:"class"`
71
+ Message string `json:"message"`
72
+ Status int `json:"status"`
73
+ RequestID *string `json:"requestId"`
74
+ Body json.RawMessage `json:"body"`
75
+ }
76
+
77
+ type golden struct {
78
+ Requests []seenRequest `json:"requests"`
79
+ Outcome outcome `json:"outcome"`
80
+ }
81
+
82
+ func readJSON(t *testing.T, path string, into any) {
83
+ t.Helper()
84
+ raw, err := os.ReadFile(path)
85
+ if err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ if err := json.Unmarshal(raw, into); err != nil {
89
+ t.Fatalf("%s: %v", path, err)
90
+ }
91
+ }
92
+
93
+ func retryOverrides(t *testing.T, m map[string]any) typesafe.RetryOverrides {
94
+ t.Helper()
95
+ var o typesafe.RetryOverrides
96
+ ms := func(v any) *time.Duration { return typesafe.Ptr(time.Duration(v.(float64)) * time.Millisecond) }
97
+ for k, v := range m {
98
+ switch k {
99
+ case "maxRetries":
100
+ o.MaxRetries = typesafe.Ptr(int(v.(float64)))
101
+ case "backoffInitialMs":
102
+ o.BackoffInitial = ms(v)
103
+ case "backoffMaxMs":
104
+ o.BackoffMax = ms(v)
105
+ case "backoffJitter":
106
+ o.BackoffJitter = typesafe.Ptr(v.(float64))
107
+ case "httpStatuses":
108
+ o.HTTPStatuses = []int{}
109
+ for _, s := range v.([]any) {
110
+ o.HTTPStatuses = append(o.HTTPStatuses, int(s.(float64)))
111
+ }
112
+ case "apiConnectionError":
113
+ o.APIConnectionError = typesafe.Ptr(v.(bool))
114
+ case "apiTimeoutError":
115
+ o.APITimeoutError = typesafe.Ptr(v.(bool))
116
+ default:
117
+ t.Fatalf("unmapped retry option %q", k)
118
+ }
119
+ }
120
+ return o
121
+ }
122
+
123
+ func buildRequest(t *testing.T, raw json.RawMessage) typesafe.SystemOneRequest {
124
+ t.Helper()
125
+ var fields map[string]json.RawMessage
126
+ if err := json.Unmarshal(raw, &fields); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ var req typesafe.SystemOneRequest
130
+ if s, ok := fields["state"]; ok {
131
+ if err := json.Unmarshal(s, &req.State); err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ }
135
+ if q, ok := fields["questions"]; ok {
136
+ qs, err := typesafe.ParseQuestions(q)
137
+ if err != nil {
138
+ // A client-side rejection of the wire form (for example a score map): reported as
139
+ // the call's outcome, like the SDK's validation.
140
+ t.Logf("ParseQuestions: %v", err)
141
+ req.Questions = nil
142
+ parseErr = err
143
+ } else {
144
+ req.Questions = qs
145
+ }
146
+ }
147
+ if m, ok := fields["model"]; ok {
148
+ _ = json.Unmarshal(m, &req.Model)
149
+ }
150
+ for k, v := range fields {
151
+ if k == "state" || k == "questions" || k == "model" {
152
+ continue
153
+ }
154
+ if req.Extra == nil {
155
+ req.Extra = map[string]any{}
156
+ }
157
+ var x any
158
+ if err := json.Unmarshal(v, &x); err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ req.Extra[k] = x
162
+ }
163
+ return req
164
+ }
165
+
166
+ // parseErr carries a ParseQuestions rejection to the outcome (single-goroutine use per scenario).
167
+ var parseErr error
168
+
169
+ // scriptedServer answers requests from steps, like record_js.mjs.
170
+ type scriptedServer struct {
171
+ srv *httptest.Server
172
+ mu sync.Mutex
173
+ seen []seenRequest
174
+ }
175
+
176
+ var keptHeader = func(n string) bool {
177
+ return strings.HasPrefix(n, "x-") || n == "authorization" || n == "accept" || n == "content-type" || n == "content-length"
178
+ }
179
+
180
+ func (s *scriptedServer) snapshot() []seenRequest {
181
+ s.srv.CloseClientConnections()
182
+ s.mu.Lock()
183
+ defer s.mu.Unlock()
184
+ return append([]seenRequest(nil), s.seen...)
185
+ }
186
+
187
+ func newScripted(steps []step) *scriptedServer {
188
+ s := &scriptedServer{}
189
+ s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190
+ body, _ := io.ReadAll(r.Body)
191
+ h := map[string]string{}
192
+ for k, v := range r.Header {
193
+ if n := strings.ToLower(k); keptHeader(n) {
194
+ h[n] = strings.Join(v, ", ")
195
+ }
196
+ }
197
+ if r.ContentLength >= 0 && len(body) > 0 {
198
+ h["content-length"] = fmt.Sprint(len(body))
199
+ }
200
+ s.mu.Lock()
201
+ s.seen = append(s.seen, seenRequest{Method: r.Method, Path: r.URL.RequestURI(), Headers: h, Body: string(body)})
202
+ n := len(s.seen)
203
+ s.mu.Unlock()
204
+ st := step{Status: 599}
205
+ text := "unscripted request"
206
+ st.BodyText = &text
207
+ if n <= len(steps) {
208
+ st = steps[n-1]
209
+ }
210
+ if st.DelayMs > 0 {
211
+ select {
212
+ case <-time.After(time.Duration(st.DelayMs) * time.Millisecond):
213
+ case <-r.Context().Done():
214
+ return
215
+ }
216
+ }
217
+ if st.Action == "destroy" {
218
+ conn, _, _ := w.(http.Hijacker).Hijack()
219
+ if tc, ok := conn.(*net.TCPConn); ok {
220
+ _ = tc.SetLinger(0)
221
+ }
222
+ _ = conn.Close()
223
+ return
224
+ }
225
+ for k, v := range st.Headers {
226
+ w.Header().Set(k, v)
227
+ }
228
+ var out []byte
229
+ switch {
230
+ case st.BodyText != nil:
231
+ out = []byte(*st.BodyText)
232
+ case st.Body != nil:
233
+ out = st.Body
234
+ w.Header().Set("content-type", "application/json")
235
+ }
236
+ w.WriteHeader(st.Status)
237
+ _, _ = w.Write(out)
238
+ }))
239
+ return s
240
+ }
241
+
242
+ func generic(t *testing.T, raw []byte) any {
243
+ t.Helper()
244
+ if len(raw) == 0 {
245
+ return nil
246
+ }
247
+ var v any
248
+ if err := json.Unmarshal(raw, &v); err != nil {
249
+ t.Fatalf("not JSON: %s", raw)
250
+ }
251
+ return v
252
+ }
253
+
254
+ func className(err error) string {
255
+ t := reflect.TypeOf(err)
256
+ for t.Kind() == reflect.Pointer {
257
+ t = t.Elem()
258
+ }
259
+ return t.Name()
260
+ }
261
+
262
+ // knownDifferences are scenarios where the Go client deliberately differs; the test asserts
263
+ // that the difference exists (so a stale entry fails) and what it is.
264
+ var knownDifferences = map[string]string{
265
+ "200-invalid-json": "the TS SDK returns the raw text as the result; the Go client decodes into typed answers and reports a *TypeSafeError",
266
+ "200-empty-body": "the TS SDK returns undefined; the Go client returns a zero result",
267
+ "unknown-answer-type-and-extra-usage": "unknown fields inside a known answer, inside usage and at the top level of the result are dropped by the typed Go result; SystemOneWithResponse and SystemOneRaw keep the complete body",
268
+ "score-map-criteria-client-side-error": "the wire form of a score map is rejected by ParseQuestions (a Go-only entry point) with a different message; Go's builders cannot express a map",
269
+ }
270
+
271
+ var runtimeHeader = regexp.MustCompile(`^go/\S+ \(\w+; \w+\)$`)
272
+
273
+ func TestCrossCheck_ClientMatchesTheOfficialSDK(t *testing.T) {
274
+ var scenarios []scenario
275
+ readJSON(t, "../../port/crosscheck/scenarios.json", &scenarios)
276
+ var goldens map[string]golden
277
+ readJSON(t, "../../port/crosscheck/golden.json", &goldens)
278
+ if len(scenarios) != len(goldens) {
279
+ t.Fatalf("%d scenarios but %d goldens: re-record with record_js.mjs", len(scenarios), len(goldens))
280
+ }
281
+ for _, sc := range scenarios {
282
+ t.Run(sc.Name, func(t *testing.T) {
283
+ want, ok := goldens[sc.Name]
284
+ if !ok {
285
+ t.Fatal("no golden")
286
+ }
287
+ parseErr = nil
288
+ srv := newScripted(sc.Responses)
289
+ defer srv.srv.Close()
290
+ cfg := typesafe.Config{
291
+ APIKey: "test-key-not-real", BaseURL: srv.srv.URL + sc.Config.BaseURLSuffix, LogLevel: typesafe.LogOff,
292
+ Retry: retryOverrides(t, sc.Config.Retry), Timeout: time.Duration(sc.Config.TimeoutMs) * time.Millisecond,
293
+ DefaultHeaders: sc.Config.DefaultHdrs, Getenv: func(string) string { return "" },
294
+ }
295
+ client, err := typesafe.NewClient(cfg)
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ opts := &typesafe.RequestOptions{
300
+ Headers: sc.Call.Options.Headers, Retry: retryOverrides(t, sc.Call.Options.Retry),
301
+ Timeout: time.Duration(sc.Call.Options.TimeoutMs) * time.Millisecond,
302
+ }
303
+ var got outcome
304
+ ctx := t.Context()
305
+ var res any
306
+ var callErr error
307
+ if sc.Call.Kind == "modelsList" {
308
+ res, callErr = client.Models().List(ctx, opts)
309
+ } else {
310
+ req := buildRequest(t, sc.Call.Request)
311
+ if parseErr != nil {
312
+ callErr = parseErr
313
+ } else {
314
+ res, callErr = client.SystemOne(ctx, req, opts)
315
+ }
316
+ }
317
+ if callErr == nil {
318
+ raw, err := json.Marshal(res)
319
+ if err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ got = outcome{OK: true, Result: raw}
323
+ } else {
324
+ got = outcome{Class: className(callErr), Message: callErr.Error()}
325
+ var api *typesafe.APIError
326
+ if errors.As(callErr, &api) {
327
+ got.Status = api.Status
328
+ id := api.RequestID
329
+ if id != "" {
330
+ got.RequestID = &id
331
+ }
332
+ if api.Body != nil {
333
+ b, _ := json.Marshal(api.Body)
334
+ got.Body = b
335
+ }
336
+ }
337
+ }
338
+ compareRequests(t, want.Requests, srv.snapshot())
339
+ diffs := compareOutcome(t, want.Outcome, got)
340
+ if reason, known := knownDifferences[sc.Name]; known {
341
+ if len(diffs) == 0 {
342
+ t.Fatalf("listed as a known difference (%s) but the outcomes are equal", reason)
343
+ }
344
+ t.Logf("known difference: %s: %v", reason, diffs)
345
+ } else if len(diffs) > 0 {
346
+ t.Errorf("outcome differs from the official SDK:\n %s\n official: %+v\n go: %+v", strings.Join(diffs, "\n "), want.Outcome, got)
347
+ }
348
+ })
349
+ }
350
+ }
351
+
352
+ func compareRequests(t *testing.T, want, got []seenRequest) {
353
+ t.Helper()
354
+ if len(want) != len(got) {
355
+ t.Errorf("request count: official %d, go %d", len(want), len(got))
356
+ return
357
+ }
358
+ for i := range want {
359
+ w, g := want[i], got[i]
360
+ tag := fmt.Sprintf("request %d", i)
361
+ if w.Method != g.Method || w.Path != g.Path {
362
+ t.Errorf("%s: official %s %s, go %s %s", tag, w.Method, w.Path, g.Method, g.Path)
363
+ }
364
+ if !reflect.DeepEqual(generic(t, []byte(w.Body)), generic(t, []byte(g.Body))) {
365
+ t.Errorf("%s body differs:\n official %s\n go %s", tag, w.Body, g.Body)
366
+ }
367
+ if (w.Body == "") != (g.Body == "") {
368
+ t.Errorf("%s: body presence differs", tag)
369
+ }
370
+ // Key order of the fixed fields: state, questions, model.
371
+ if w.Body != "" && orderOfFixedKeys(w.Body) != orderOfFixedKeys(g.Body) {
372
+ t.Errorf("%s: field order differs: official %s, go %s", tag, orderOfFixedKeys(w.Body), orderOfFixedKeys(g.Body))
373
+ }
374
+ names := map[string]bool{}
375
+ for n := range w.Headers {
376
+ names[n] = true
377
+ }
378
+ for n := range g.Headers {
379
+ names[n] = true
380
+ }
381
+ var sorted []string
382
+ for n := range names {
383
+ sorted = append(sorted, n)
384
+ }
385
+ sort.Strings(sorted)
386
+ for _, n := range sorted {
387
+ wv, wok := w.Headers[n]
388
+ gv, gok := g.Headers[n]
389
+ switch n {
390
+ case "x-typesafe-sdk":
391
+ if !gok || gv != "typesafe-sdk-go/0.6.0" {
392
+ t.Errorf("%s: x-typesafe-sdk = %q, want typesafe-sdk-go/0.6.0 (official %q)", tag, gv, wv)
393
+ }
394
+ case "x-typesafe-runtime":
395
+ if !gok || !runtimeHeader.MatchString(gv) {
396
+ t.Errorf("%s: x-typesafe-runtime = %q, want go/<v> (<os>; <arch>) (official %q)", tag, gv, wv)
397
+ }
398
+ case "content-length":
399
+ // encoding/json always escapes U+2028 and U+2029 (\u2028: 6 bytes instead of 3).
400
+ n := strings.Count(w.Body, "\u2028") + strings.Count(w.Body, "\u2029")
401
+ if wv2 := fmt.Sprint(len(w.Body) + 3*n); wv2 != gv {
402
+ t.Errorf("%s: content-length official %q (expected %s with escapes), go %q", tag, wv, wv2, gv)
403
+ }
404
+ default:
405
+ if wok != gok || wv != gv {
406
+ t.Errorf("%s: header %s: official %q (present %v), go %q (present %v)", tag, n, wv, wok, gv, gok)
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ // orderOfFixedKeys returns the order of the top-level state/questions/model keys in a body.
414
+ func orderOfFixedKeys(body string) string {
415
+ dec := json.NewDecoder(strings.NewReader(body))
416
+ var order []string
417
+ if _, err := dec.Token(); err != nil {
418
+ return ""
419
+ }
420
+ for dec.More() {
421
+ k, _ := dec.Token()
422
+ key := k.(string)
423
+ var skip json.RawMessage
424
+ _ = dec.Decode(&skip)
425
+ if key == "state" || key == "questions" || key == "model" {
426
+ order = append(order, key)
427
+ }
428
+ }
429
+ return strings.Join(order, ",")
430
+ }
431
+
432
+ func compareOutcome(t *testing.T, want, got outcome) []string {
433
+ t.Helper()
434
+ var diffs []string
435
+ if want.OK != got.OK {
436
+ return []string{fmt.Sprintf("ok: official %v, go %v", want.OK, got.OK)}
437
+ }
438
+ if want.OK {
439
+ if !reflect.DeepEqual(generic(t, want.Result), generic(t, got.Result)) {
440
+ diffs = append(diffs, fmt.Sprintf("result: official %s, go %s", want.Result, got.Result))
441
+ }
442
+ return diffs
443
+ }
444
+ if want.Class != got.Class {
445
+ diffs = append(diffs, fmt.Sprintf("class: official %s, go %s", want.Class, got.Class))
446
+ }
447
+ wm, gm := want.Message, got.Message
448
+ if want.Class == "APIConnectionError" { // the cause text belongs to the runtime's HTTP stack
449
+ wm, gm = strings.SplitN(wm, ":", 2)[0], strings.SplitN(gm, ":", 2)[0]
450
+ }
451
+ if wm != gm {
452
+ diffs = append(diffs, fmt.Sprintf("message: official %q, go %q", want.Message, got.Message))
453
+ }
454
+ if want.Status != got.Status {
455
+ diffs = append(diffs, fmt.Sprintf("status: official %d, go %d", want.Status, got.Status))
456
+ }
457
+ if (want.RequestID == nil) != (got.RequestID == nil) || (want.RequestID != nil && *want.RequestID != *got.RequestID) {
458
+ diffs = append(diffs, "request id differs")
459
+ }
460
+ if !reflect.DeepEqual(generic(t, want.Body), generic(t, got.Body)) {
461
+ diffs = append(diffs, fmt.Sprintf("body: official %s, go %s", want.Body, got.Body))
462
+ }
463
+ return diffs
464
+ }
@@ -0,0 +1,219 @@
1
+ package typesafe_test
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "sort"
11
+ "strconv"
12
+ "strings"
13
+ "testing"
14
+
15
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
16
+ )
17
+
18
+ // WorkflowEvals cross-check (typesafe-ai/WorkflowEvals, Apache-2.0, pinned in PORT.md).
19
+ // extract_workflowevals.py runs the four workflows' own eval adapters and records every
20
+ // system_one call: the real question sets, and the request body the Python SDK builds.
21
+ // record_js.mjs sends the same inputs through the official TS SDK and records a hash of the
22
+ // canonical body. Here the Go client sends the same inputs to a fake server and its body must
23
+ // equal both, whatever the key order or escaping.
24
+
25
+ type weCase struct {
26
+ Workflow string `json:"workflow"`
27
+ CaseID string `json:"case_id"`
28
+ Node string `json:"node"`
29
+ State json.RawMessage `json:"state"`
30
+ Questions json.RawMessage `json:"questions"`
31
+ PythonBody string `json:"python_body"`
32
+ }
33
+
34
+ type weGolden struct {
35
+ SHA256 string `json:"sha256"`
36
+ Order string `json:"order"`
37
+ }
38
+
39
+ // canon renders JSON with sorted keys, compact, and non-ASCII escaped, like record_js.mjs.
40
+ func canon(t testing.TB, raw []byte) string {
41
+ t.Helper()
42
+ dec := json.NewDecoder(bytes.NewReader(raw))
43
+ dec.UseNumber()
44
+ var v any
45
+ if err := dec.Decode(&v); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ var b strings.Builder
49
+ canonWrite(&b, v)
50
+ return b.String()
51
+ }
52
+
53
+ func canonString(b *strings.Builder, s string) {
54
+ b.WriteByte('"')
55
+ for _, r := range s {
56
+ switch {
57
+ case r == '"':
58
+ b.WriteString(`\"`)
59
+ case r == '\\':
60
+ b.WriteString(`\\`)
61
+ case r == '\n':
62
+ b.WriteString(`\n`)
63
+ case r == '\r':
64
+ b.WriteString(`\r`)
65
+ case r == '\t':
66
+ b.WriteString(`\t`)
67
+ case r == '\b':
68
+ b.WriteString(`\b`)
69
+ case r == '\f':
70
+ b.WriteString(`\f`)
71
+ case r < 0x20 || (r >= 0x7f && r <= 0xffff):
72
+ fmt.Fprintf(b, `\u%04x`, r)
73
+ case r > 0xffff:
74
+ r -= 0x10000
75
+ fmt.Fprintf(b, `\u%04x\u%04x`, 0xd800+(r>>10), 0xdc00+(r&0x3ff))
76
+ default:
77
+ b.WriteRune(r)
78
+ }
79
+ }
80
+ b.WriteByte('"')
81
+ }
82
+
83
+ func canonWrite(b *strings.Builder, v any) {
84
+ switch x := v.(type) {
85
+ case map[string]any:
86
+ keys := make([]string, 0, len(x))
87
+ for k := range x {
88
+ keys = append(keys, k)
89
+ }
90
+ sort.Strings(keys)
91
+ b.WriteByte('{')
92
+ for i, k := range keys {
93
+ if i > 0 {
94
+ b.WriteByte(',')
95
+ }
96
+ canonString(b, k)
97
+ b.WriteByte(':')
98
+ canonWrite(b, x[k])
99
+ }
100
+ b.WriteByte('}')
101
+ case []any:
102
+ b.WriteByte('[')
103
+ for i, e := range x {
104
+ if i > 0 {
105
+ b.WriteByte(',')
106
+ }
107
+ canonWrite(b, e)
108
+ }
109
+ b.WriteByte(']')
110
+ case string:
111
+ canonString(b, x)
112
+ case json.Number:
113
+ if f, err := x.Float64(); err == nil && strings.ContainsAny(x.String(), ".eE") {
114
+ b.WriteString(strconv.FormatFloat(f, 'f', -1, 64))
115
+ } else {
116
+ b.WriteString(x.String())
117
+ }
118
+ case bool:
119
+ b.WriteString(strconv.FormatBool(x))
120
+ case nil:
121
+ b.WriteString("null")
122
+ }
123
+ }
124
+
125
+ func keyOrder(body string) string {
126
+ dec := json.NewDecoder(strings.NewReader(body))
127
+ var keys []string
128
+ if _, err := dec.Token(); err != nil {
129
+ return ""
130
+ }
131
+ for dec.More() {
132
+ k, _ := dec.Token()
133
+ keys = append(keys, k.(string))
134
+ var skip json.RawMessage
135
+ _ = dec.Decode(&skip)
136
+ }
137
+ return strings.Join(keys, ",")
138
+ }
139
+
140
+ // goBody returns the body the Go client sends for a WorkflowEvals call.
141
+ func goBody(t *testing.T, c weCase) string {
142
+ t.Helper()
143
+ srv := newScripted([]step{{Status: 200, Body: json.RawMessage(`{"model":"m","answers":{},"usage":{"input_tokens":0,"output_tokens":0}}`)}})
144
+ defer srv.srv.Close()
145
+ client, err := typesafe.NewClient(typesafe.Config{APIKey: "test-key-not-real", BaseURL: srv.srv.URL, LogLevel: typesafe.LogOff,
146
+ Retry: typesafe.RetryOverrides{MaxRetries: typesafe.Ptr(0)}, Getenv: func(string) string { return "" }})
147
+ if err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ qs, err := typesafe.ParseQuestions(c.Questions)
151
+ if err != nil {
152
+ t.Fatalf("ParseQuestions: %v", err)
153
+ }
154
+ var state typesafe.Entry
155
+ if err := json.Unmarshal(c.State, &state); err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ if _, err := client.SystemOne(t.Context(), typesafe.SystemOneRequest{State: state, Questions: qs, Model: "m"}, nil); err != nil {
159
+ t.Fatalf("SystemOne: %v", err)
160
+ }
161
+ seen := srv.snapshot()
162
+ if len(seen) != 1 {
163
+ t.Fatalf("%d requests", len(seen))
164
+ }
165
+ return seen[0].Body
166
+ }
167
+
168
+ func TestCrossCheck_WorkflowEvalsRequestsMatchTheOfficialSDKAndThePythonSDK(t *testing.T) {
169
+ var cases []weCase
170
+ readJSON(t, "../../port/crosscheck/workflowevals.json", &cases)
171
+ var goldens []weGolden
172
+ readJSON(t, "../../port/crosscheck/golden_workflowevals.json", &goldens)
173
+ if len(cases) != len(goldens) || len(cases) == 0 {
174
+ t.Fatalf("%d cases, %d goldens: re-extract and re-record", len(cases), len(goldens))
175
+ }
176
+ kinds := map[string]int{}
177
+ for i, c := range cases {
178
+ t.Run(fmt.Sprintf("%s-%s-%d", c.Workflow, c.Node, i), func(t *testing.T) {
179
+ body := goBody(t, c)
180
+ sum := sha256.Sum256([]byte(canon(t, []byte(body))))
181
+ if got := hex.EncodeToString(sum[:]); got != goldens[i].SHA256 {
182
+ t.Errorf("Go body differs from the official SDK's:\n go %s\n python %s", canon(t, []byte(body)), canon(t, []byte(c.PythonBody)))
183
+ }
184
+ if canon(t, []byte(body)) != canon(t, []byte(c.PythonBody)) {
185
+ t.Errorf("Go body differs from the Python SDK's body")
186
+ }
187
+ if got := keyOrder(body); got != goldens[i].Order {
188
+ t.Errorf("key order: Go %s, official %s", got, goldens[i].Order)
189
+ }
190
+ var qs map[string]struct {
191
+ Type string `json:"type"`
192
+ }
193
+ _ = json.Unmarshal(c.Questions, &qs)
194
+ for _, q := range qs {
195
+ kinds[q.Type]++
196
+ }
197
+ })
198
+ }
199
+ t.Logf("%d calls; question types seen: %v", len(cases), kinds)
200
+ }
201
+
202
+ // WORKFLOWEVALS_FULL names a file written by extract_workflowevals.py's second argument: real
203
+ // dataset states (not committed). The Go body must equal the Python SDK's body for them too.
204
+ func TestCrossCheck_WorkflowEvalsRealStatesMatchThePythonSDK(t *testing.T) {
205
+ path := os.Getenv("WORKFLOWEVALS_FULL")
206
+ if path == "" {
207
+ t.Skip("WORKFLOWEVALS_FULL is not set (the real dataset states are not committed)")
208
+ }
209
+ var cases []weCase
210
+ readJSON(t, path, &cases)
211
+ for i, c := range cases {
212
+ t.Run(fmt.Sprintf("%s-%s-%d", c.Workflow, c.Node, i), func(t *testing.T) {
213
+ body := goBody(t, c)
214
+ if canon(t, []byte(body)) != canon(t, []byte(c.PythonBody)) {
215
+ t.Errorf("Go body differs from the Python SDK's body")
216
+ }
217
+ })
218
+ }
219
+ }
@@ -0,0 +1,27 @@
1
+ // Package typesafe is a Go client for the TypeSafe AI API: a port of the official
2
+ // TypeScript SDK @typesafe-ai/sdk 0.6.0 (https://github.com/typesafe-ai/typesafe-sdk-js,
3
+ // MIT; see the component's CREDITS.md and port/PORT.md for the pinned commit and the
4
+ // file-by-file mapping).
5
+ //
6
+ // A request carries a state (text, a JSON object or array, or null) and named typed
7
+ // questions. Each answer is typed by the question that produced it:
8
+ //
9
+ // client, err := typesafe.NewClient(typesafe.Config{}) // TYPESAFE_API_KEY from the environment
10
+ // res, err := client.SystemOne(ctx, typesafe.SystemOneRequest{
11
+ // State: typesafe.Text("I was charged twice. Please fix this ASAP."),
12
+ // Questions: typesafe.Questions{
13
+ // typesafe.Ask("category", typesafe.Choice("What is this ticket about?",
14
+ // typesafe.Opt("billing", nil), typesafe.Opt("technical", nil), typesafe.Opt("other", nil))),
15
+ // },
16
+ // }, nil)
17
+ // answer, err := res.Choice("category") // ChoiceAnswer{Choice, Confidence, Probabilities}
18
+ //
19
+ // The three question kinds are Noul (yes/no), Choice (named alternatives) and Score
20
+ // (an ordered rubric). The [Evaluator] interface is what code that only needs answers
21
+ // depends on; [Client] (the TypeSafe API) and the own-model backend in package
22
+ // ownmodel both implement it. [EvaluateBatch] runs many requests through an Evaluator
23
+ // with bounded concurrency.
24
+ //
25
+ // The package uses the standard library only. See CONTRACT.md in the component root
26
+ // for the deliberate differences from the TypeScript SDK.
27
+ package typesafe