@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,490 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "strings"
10
+ "sync"
11
+ "sync/atomic"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
16
+ )
17
+
18
+ func testClient(t *testing.T, opts Options, doer typesafe.HTTPDoer) *TypeSafe {
19
+ t.Helper()
20
+ if opts.APIKey == "" && opts.Backend == nil {
21
+ opts.APIKey = "test-key"
22
+ }
23
+ if opts.HTTPClient == nil {
24
+ opts.HTTPClient = doer
25
+ }
26
+ c, err := New(opts)
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ return c
31
+ }
32
+
33
+ func failing(status int, header http.Header, calls *atomic.Int32) doerFunc {
34
+ return func(*http.Request) (*http.Response, error) {
35
+ if calls != nil {
36
+ calls.Add(1)
37
+ }
38
+ return jsonResponse(status, map[string]any{"error": map[string]any{"code": "x", "message": "never-print-me"}, "secret": "never-print-me"}, header), nil
39
+ }
40
+ }
41
+
42
+ func TestClient(t *testing.T) {
43
+ tw(t, "client", "official SDK helpers, typed answers, metadata, and one batched network call", func(t *testing.T) {
44
+ isolate(t)
45
+ calls := 0
46
+ questions := typesafe.Questions{
47
+ typesafe.Ask("category", typesafe.Choice("Which?", typesafe.Opt("billing", "Charges"), typesafe.Opt("other", nil))),
48
+ typesafe.Ask("urgent", typesafe.Noul("Urgent?")),
49
+ typesafe.Ask("frustration", typesafe.Score("Frustration?", "Calm", "Angry")),
50
+ }
51
+ client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
52
+ calls++
53
+ body := requestBody(t, r)
54
+ var req struct {
55
+ Model string `json:"model"`
56
+ Questions json.RawMessage `json:"questions"`
57
+ }
58
+ _ = json.Unmarshal(body, &req)
59
+ want, _ := json.Marshal(questions)
60
+ if r.URL.String() != "https://api.typesafe.ai/v1/systemone" || r.Method != http.MethodPost || string(req.Questions) != string(want) || req.Model != "jev-latest" || !strings.Contains(r.Header.Get("Authorization"), "test-key") {
61
+ t.Errorf("url=%s method=%s model=%s questions=%s", r.URL, r.Method, req.Model, req.Questions)
62
+ }
63
+ return responseFor(body), nil
64
+ }))
65
+ result, err := client.Evaluate(context.Background(), typesafe.SystemOneRequest{State: typesafe.Value(map[string]any{"message": "Example"}), Questions: questions})
66
+ if err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ category, _ := result.Choice("category")
70
+ urgent, _ := result.Noul("urgent")
71
+ frustration, _ := result.Score("frustration")
72
+ if category.Choice != "billing" || urgent.Noul != 0.9 || frustration.Score != 0 || calls != 1 || result.ElapsedMs < 0 {
73
+ t.Fatalf("answers = %+v %+v %+v calls=%d", category, urgent, frustration, calls)
74
+ }
75
+ if got := client.GetUsage(); got != (UsageSnapshot{RequestsStarted: 1, RequestsSucceeded: 1, InputTokens: 42, EstimatedUSD: 0.000002}) {
76
+ t.Errorf("usage = %+v", got)
77
+ }
78
+ spend := client.GetSpend()
79
+ if spend.Session.RequestsStarted != 1 || spend.Today.InputTokens != 42 || spend.Blocked != nil {
80
+ t.Errorf("spend = %+v", spend)
81
+ }
82
+ })
83
+ tw(t, "client", "invalid questions are rejected before network submission without echoing state", func(t *testing.T) {
84
+ isolate(t)
85
+ var calls atomic.Int32
86
+ client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) {
87
+ calls.Add(1)
88
+ return nil, errors.New(secret)
89
+ }))
90
+ invalid := []any{
91
+ `{"state":"` + secret + `","questions":{}}`,
92
+ `{"state":"` + secret + `","questions":{"q":{"type":"score","criteria":["one"]}}}`,
93
+ `{"state":"` + secret + `","questions":{"q":{"type":"choice","criteria":{}}}}`,
94
+ `{"state":"` + secret + `","questions":{"q":{"type":"unsupported"}}}`,
95
+ `{"state":"` + secret + `","questions":{"q":{"type":"noul","instructions":"yes"}},"apiKey":"` + secret + `"}`,
96
+ }
97
+ var tooMany []string
98
+ for i := 0; i < 33; i++ {
99
+ tooMany = append(tooMany, fmt.Sprintf(`"q%d":{"type":"noul","instructions":"yes"}`, i))
100
+ }
101
+ invalid = append(invalid, `{"state":"`+secret+`","questions":{`+strings.Join(tooMany, ",")+`}}`)
102
+ for _, text := range invalid {
103
+ _, err := client.EvaluateRaw(context.Background(), json.RawMessage(text.(string)))
104
+ if !hasCode(err, CodeValidation) || strings.Contains(err.Error(), secret) {
105
+ t.Errorf("%.60s: %v", text, err)
106
+ }
107
+ }
108
+ if calls.Load() != 0 || client.GetUsage().RequestsStarted != 0 {
109
+ t.Fatalf("calls=%d usage=%+v", calls.Load(), client.GetUsage())
110
+ }
111
+ })
112
+ tw(t, "client", "UTF-8 byte limit applies before submission", func(t *testing.T) {
113
+ isolate(t)
114
+ client := testClient(t, Options{MaxInputBytes: 180}, doerFunc(func(*http.Request) (*http.Response, error) { t.Error("must not run"); return nil, nil }))
115
+ req := sampleRequest()
116
+ req.State = typesafe.Text(strings.Repeat("🙂", 50))
117
+ if _, err := client.Evaluate(context.Background(), req); !hasCode(err, CodeValidation) {
118
+ t.Fatalf("err = %v", err)
119
+ }
120
+ if client.GetUsage().RequestsStarted != 0 {
121
+ t.Fatal("a refused request must not count")
122
+ }
123
+ })
124
+ tw(t, "client", "request budget is shared across concurrent calls and failures", func(t *testing.T) {
125
+ isolate(t)
126
+ var calls atomic.Int32
127
+ client := testClient(t, Options{MaxRequests: 2, Ledger: OpenUsageLedger(LedgerOptions{Path: t.TempDir() + "/u.json"})}, failing(500, nil, &calls))
128
+ var wg sync.WaitGroup
129
+ errs := make([]error, 3)
130
+ for i := range errs {
131
+ wg.Add(1)
132
+ go func() { defer wg.Done(); _, errs[i] = client.Evaluate(context.Background(), sampleRequest()) }()
133
+ }
134
+ wg.Wait()
135
+ for i, err := range errs {
136
+ if err == nil {
137
+ t.Errorf("call %d succeeded", i)
138
+ }
139
+ }
140
+ if calls.Load() != 2 {
141
+ t.Errorf("calls = %d", calls.Load())
142
+ }
143
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeBudget) {
144
+ t.Errorf("err = %v", err)
145
+ }
146
+ if u := client.GetUsage(); u.RequestsStarted != 2 || u.RequestsSucceeded != 0 {
147
+ t.Errorf("usage = %+v", u)
148
+ }
149
+ })
150
+ tw(t, "client", "HTTP errors are classified, never retried, and do not expose response secrets", func(t *testing.T) {
151
+ for _, status := range []int{400, 401, 403, 422, 429, 500, 503} {
152
+ isolate(t)
153
+ var calls atomic.Int32
154
+ client := testClient(t, Options{}, failing(status, nil, &calls))
155
+ _, err := client.Evaluate(context.Background(), sampleRequest())
156
+ ie, ok := err.(*IntegrationError)
157
+ if !ok || ie.Code != CodeHTTP || ie.Status != status || strings.Contains(ie.Message, "never-print-me") {
158
+ t.Errorf("%d: %v", status, err)
159
+ }
160
+ if data, _ := json.Marshal(err); strings.Contains(string(data), "never-print-me") {
161
+ t.Errorf("%d: secret in JSON", status)
162
+ }
163
+ if calls.Load() != 1 {
164
+ t.Errorf("%d: %d calls", status, calls.Load())
165
+ }
166
+ }
167
+ })
168
+ tw(t, "client", "HTTP advice names the backend's key, covers 402, and quotes a numeric Retry-After", func(t *testing.T) {
169
+ cases := []struct {
170
+ backend string
171
+ status int
172
+ retryAfter bool
173
+ usable bool
174
+ message string
175
+ }{
176
+ {"typesafe", 401, true, false, "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made."},
177
+ {"openrouter", 401, true, false, "TypeSafe returned HTTP 401. Check OPENROUTER_API_KEY. No automatic retry was made."},
178
+ {"typesafe", 402, true, true, "TypeSafe returned HTTP 402. Check your account balance. No automatic retry was made."},
179
+ {"openrouter", 402, true, true, "TypeSafe returned HTTP 402. Insufficient credits. Add credits at https://openrouter.ai/credits. No automatic retry was made."},
180
+ {"typesafe", 429, true, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. Retry after 7 seconds. No automatic retry was made."},
181
+ {"openrouter", 429, true, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. Retry after 7 seconds. No automatic retry was made."},
182
+ {"typesafe", 429, false, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. No automatic retry was made."},
183
+ }
184
+ for _, c := range cases {
185
+ isolate(t)
186
+ // authState({ backend }).usable needs a key present for each backend; the client itself takes its key below.
187
+ t.Setenv("TYPESAFE_API_KEY", "offline-env-key-0123456789abcdef")
188
+ t.Setenv("OPENROUTER_API_KEY", "offline-or-key-0123456789abcdef")
189
+ header := http.Header{}
190
+ if c.retryAfter {
191
+ header.Set("Retry-After", "7")
192
+ }
193
+ client, err := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: failing(c.status, header, nil)})
194
+ if err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ _, err = client.Evaluate(context.Background(), sampleRequest())
198
+ ie, ok := err.(*IntegrationError)
199
+ if !ok || ie.Code != CodeHTTP || ie.Status != c.status || ie.Message != c.message || strings.Contains(ie.Message, "never-print-me") {
200
+ t.Errorf("%s %d: %v", c.backend, c.status, err)
201
+ }
202
+ // A 401 rejects the key; a 402 is billing, so it must leave it usable (rejected statuses stay 401 and 403).
203
+ if s := mustAuth(t, c.backend); s.Usable != c.usable {
204
+ t.Errorf("%s %d: usable = %v", c.backend, c.status, s.Usable)
205
+ }
206
+ }
207
+ })
208
+ tw(t, "client", "safeError keeps its one-argument form and defaults the key advice", func(t *testing.T) {
209
+ own := &IntegrationError{Code: CodeHTTP, Message: "kept", Status: 401}
210
+ if SafeError(own, nil) != own {
211
+ t.Error("an IntegrationError must pass through")
212
+ }
213
+ if got := SafeError(apiError(401), nil).Message; got != "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made." {
214
+ t.Errorf("message = %s", got)
215
+ }
216
+ })
217
+ tw(t, "client", "cancellation before submission does not consume an attempt", func(t *testing.T) {
218
+ isolate(t)
219
+ client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) { t.Error("must not run"); return nil, nil }))
220
+ ctx, cancel := context.WithCancel(context.Background())
221
+ cancel()
222
+ if _, err := client.Evaluate(ctx, sampleRequest()); !hasCode(err, CodeAborted) {
223
+ t.Fatalf("err = %v", err)
224
+ }
225
+ if client.GetUsage().RequestsStarted != 0 {
226
+ t.Fatal("a cancelled request must not count")
227
+ }
228
+ })
229
+ tw(t, "client", "in-flight cancellation and timeout reach the transport", func(t *testing.T) {
230
+ isolate(t)
231
+ pending := doerFunc(func(r *http.Request) (*http.Response, error) {
232
+ <-r.Context().Done()
233
+ return nil, r.Context().Err()
234
+ })
235
+ ctx, cancel := context.WithCancel(context.Background())
236
+ client := testClient(t, Options{Timeout: time.Second}, pending)
237
+ done := make(chan error, 1)
238
+ go func() { _, err := client.Evaluate(ctx, sampleRequest()); done <- err }()
239
+ time.Sleep(20 * time.Millisecond)
240
+ cancel()
241
+ if err := <-done; !hasCode(err, CodeAborted) {
242
+ t.Fatalf("cancel: %v", err)
243
+ }
244
+ timed := testClient(t, Options{Timeout: 10 * time.Millisecond}, pending)
245
+ if _, err := timed.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeTimeout) {
246
+ t.Fatalf("timeout: %v", err)
247
+ }
248
+ })
249
+ tw(t, "client", "malformed successful responses fail safely", func(t *testing.T) {
250
+ bodies := []*http.Response{
251
+ jsonResponse(200, map[string]any{"secret": "private"}, nil),
252
+ rawResponse(200, "not-json"),
253
+ jsonResponse(200, map[string]any{"model": "test", "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}, "answers": map[string]any{"yes": map[string]any{"type": "noul", "noul": 5}}}, nil),
254
+ }
255
+ for i, response := range bodies {
256
+ isolate(t)
257
+ client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) { return response, nil }))
258
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
259
+ t.Errorf("case %d: %v", i, err)
260
+ }
261
+ if client.GetUsage().RequestsSucceeded != 0 {
262
+ t.Errorf("case %d counted a success", i)
263
+ }
264
+ }
265
+ })
266
+ tw(t, "client", "client ignores SDK endpoint and logging environment overrides", func(t *testing.T) {
267
+ isolate(t)
268
+ t.Setenv("TYPESAFE_BASE_URL", "https://untrusted.invalid")
269
+ t.Setenv("TYPESAFE_LOG_LEVEL", "debug")
270
+ client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
271
+ if !strings.HasPrefix(r.URL.String(), "https://api.typesafe.ai/") {
272
+ t.Errorf("url = %s", r.URL)
273
+ }
274
+ return responseFor(requestBody(t, r)), nil
275
+ }))
276
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
277
+ t.Fatal(err)
278
+ }
279
+ })
280
+ tw(t, "client", "configuration errors are early and usage snapshots are detached", func(t *testing.T) {
281
+ isolate(t)
282
+ if _, err := New(Options{APIKey: " "}); !hasCode(err, CodeConfiguration) {
283
+ t.Errorf("blank key: %v", err)
284
+ }
285
+ // Go zero values mean "default", so the original's timeoutMs: 0, NaN and model: "" have no counterpart; negatives and an overlong model remain.
286
+ for name, opts := range map[string]Options{"timeout": {Timeout: -1}, "maxRequests": {MaxRequests: -1}, "maxInputBytes": {MaxInputBytes: -5}, "model": {Model: strings.Repeat("x", 101)}} {
287
+ opts.APIKey = "test-key"
288
+ if _, err := New(opts); !hasCode(err, CodeConfiguration) {
289
+ t.Errorf("%s: %v", name, err)
290
+ }
291
+ }
292
+ client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) { return responseFor(requestBody(t, r)), nil }))
293
+ snapshot := client.GetUsage()
294
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
295
+ t.Fatal(err)
296
+ }
297
+ if snapshot.RequestsStarted != 0 || client.GetUsage().RequestsStarted != 1 {
298
+ t.Fatal("snapshots must be detached")
299
+ }
300
+ })
301
+ tw(t, "client", "request data is snapshotted before asynchronous work", func(t *testing.T) {
302
+ isolate(t)
303
+ var sent atomic.Value
304
+ release, entered := make(chan struct{}), make(chan struct{})
305
+ client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
306
+ close(entered)
307
+ <-release
308
+ body := requestBody(t, r)
309
+ sent.Store(string(body))
310
+ return responseFor(body), nil
311
+ }))
312
+ req := sampleRequest()
313
+ done := make(chan error, 1)
314
+ go func() { _, err := client.Evaluate(context.Background(), req); done <- err }()
315
+ <-entered // the request is in flight, so it has been snapshotted
316
+ req.State = typesafe.Text("changed")
317
+ req.Questions[0] = typesafe.Ask("other", typesafe.Noul("changed?"))
318
+ close(release)
319
+ if err := <-done; err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ if s, _ := sent.Load().(string); !strings.Contains(s, `"state":"synthetic"`) || strings.Contains(s, "changed") {
323
+ t.Fatalf("sent = %s", s)
324
+ }
325
+ })
326
+ tw(t, "client", "evaluate admits the same near-miss aliases as the agent tool", func(t *testing.T) {
327
+ isolate(t)
328
+ var sent string
329
+ client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
330
+ body := requestBody(t, r)
331
+ sent = string(body)
332
+ return responseFor(body), nil
333
+ }))
334
+ near := map[string]any{"state": "synthetic", "questions": map[string]any{"yes": map[string]any{"type": "noul", "instructions": "Is this synthetic?", "criteria": "Is this synthetic data?"}}}
335
+ result, err := client.EvaluateRaw(context.Background(), near)
336
+ if err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ yes, _ := result.Noul("yes")
340
+ if yes.Noul != 0.9 || !strings.Contains(sent, `"criteria":{"true":"Is this synthetic data?"}`) {
341
+ t.Fatalf("yes=%+v sent=%s", yes, sent)
342
+ }
343
+ })
344
+ tw(t, "client", "unknown backend throws configuration error", func(t *testing.T) {
345
+ _, err := New(Options{APIKey: "test-key", Backend: "bogus"})
346
+ if !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "Unknown judgment backend") || !strings.Contains(err.Error(), "bogus") {
347
+ t.Fatalf("err = %v", err)
348
+ }
349
+ })
350
+ tw(t, "client", "known backend is called at its full request URL", func(t *testing.T) {
351
+ // A host does not identify the endpoint: openrouter.ai answers the SDK's own /v1/systemone with an HTML page and status 200.
352
+ for backend, want := range map[string]string{"typesafe": "https://api.typesafe.ai/v1/systemone", "openrouter": "https://openrouter.ai/api/alpha/decisions"} {
353
+ isolate(t)
354
+ var got string
355
+ client, err := New(Options{APIKey: "test-key", Backend: backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
356
+ got = r.URL.String()
357
+ return responseFor(requestBody(t, r)), nil
358
+ })})
359
+ if err != nil {
360
+ t.Fatal(err)
361
+ }
362
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || got != want {
363
+ t.Errorf("%s: %v url=%s", backend, err, got)
364
+ }
365
+ }
366
+ })
367
+ tw(t, "client", "listModels asks each backend for its own model list", func(t *testing.T) {
368
+ cases := []struct {
369
+ backend, url string
370
+ wire any
371
+ want string
372
+ }{
373
+ {"typesafe", "https://api.typesafe.ai/v1/models", map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, "jev-latest"},
374
+ {"openrouter", "https://openrouter.ai/api/v1/models", map[string]any{"data": []any{map[string]any{"id": "vendor/model", "name": "Vendor: Model"}}}, "vendor/model"},
375
+ }
376
+ for _, c := range cases {
377
+ isolate(t)
378
+ var got string
379
+ client, _ := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
380
+ got = r.URL.String()
381
+ return jsonResponse(200, c.wire, nil), nil
382
+ })})
383
+ names, err := client.ListModels(context.Background())
384
+ if err != nil || len(names) != 1 || names[0] != c.want || got != c.url {
385
+ t.Errorf("%s: %v %v url=%s", c.backend, names, err, got)
386
+ }
387
+ }
388
+ })
389
+ tw(t, "client", "a model list without the backend's declared field keeps the SDK's own shape error", func(t *testing.T) {
390
+ isolate(t)
391
+ client, _ := New(Options{APIKey: "test-key", Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
392
+ return jsonResponse(200, map[string]any{"items": []any{map[string]any{"name": "not the declared field"}}}, nil), nil
393
+ })})
394
+ if _, err := client.ListModels(context.Background()); !hasCode(err, CodeResponse) {
395
+ t.Fatalf("err = %v", err)
396
+ }
397
+ })
398
+ tw(t, "client", "the model list is renamed only for the backend that declares another field", func(t *testing.T) {
399
+ isolate(t)
400
+ client, _ := New(Options{APIKey: "test-key", Backend: "typesafe", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
401
+ return jsonResponse(200, map[string]any{"data": []any{map[string]any{"name": "not the SDK's field"}}}, nil), nil
402
+ })})
403
+ if _, err := client.ListModels(context.Background()); !hasCode(err, CodeResponse) {
404
+ t.Fatalf("err = %v", err)
405
+ }
406
+ })
407
+ tw(t, "client", "a public model list leaves the auth state unverified", func(t *testing.T) {
408
+ isolate(t)
409
+ ClearAuthState()
410
+ client, _ := New(Options{APIKey: "garbage-key", Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
411
+ return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "vendor/model", "name": "Vendor: Model"}}}, nil), nil
412
+ })})
413
+ names, err := client.ListModels(context.Background())
414
+ // openrouter.ai serves this list to anyone, so a success says nothing about the key.
415
+ if err != nil || len(names) != 1 || names[0] != "vendor/model" || mustAuth(t, "openrouter").Verified {
416
+ t.Fatalf("names=%v err=%v", names, err)
417
+ }
418
+ })
419
+ tw(t, "client", "the model in the request body is the backend's own id form", func(t *testing.T) {
420
+ cases := []struct{ backend, requested, want string }{
421
+ {"openrouter", "", "typesafe/jev-1.13"}, {"openrouter", "jev-latest", "~typesafe/jev-latest"}, {"openrouter", "jev-1.13", "typesafe/jev-1.13"},
422
+ {"openrouter", "jev-1.13.0", "typesafe/jev-1.13"}, {"openrouter", "vendor/other", "vendor/other"}, {"typesafe", "jev-latest", "jev-latest"},
423
+ }
424
+ for _, c := range cases {
425
+ isolate(t)
426
+ var url, model string
427
+ client, err := New(Options{APIKey: "test-key", Backend: c.backend, Model: c.requested, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
428
+ url = r.URL.String()
429
+ body := requestBody(t, r)
430
+ var sent struct{ Model string }
431
+ _ = json.Unmarshal(body, &sent)
432
+ model = sent.Model
433
+ return responseFor(body), nil
434
+ })})
435
+ if err != nil {
436
+ t.Fatal(err)
437
+ }
438
+ if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || model != c.want {
439
+ t.Errorf("%+v: model=%s err=%v", c, model, err)
440
+ }
441
+ // OpenRouter judgments travel to its own decisions path, so the mapped id is proven on the wire that uses it.
442
+ wantURL := map[bool]string{true: "https://openrouter.ai/api/alpha/decisions", false: "https://api.typesafe.ai/v1/systemone"}[c.backend == "openrouter"]
443
+ if url != wantURL {
444
+ t.Errorf("%+v: url=%s", c, url)
445
+ }
446
+ }
447
+ })
448
+ tw(t, "client", "a per-request model gets the same mapping as the client default", func(t *testing.T) {
449
+ cases := []struct{ backend, requested, want string }{
450
+ {"openrouter", "jev-latest", "~typesafe/jev-latest"}, {"openrouter", "jev-1.13", "typesafe/jev-1.13"}, {"openrouter", "vendor/other", "vendor/other"}, {"typesafe", "jev-latest", "jev-latest"},
451
+ }
452
+ for _, c := range cases {
453
+ isolate(t)
454
+ var model string
455
+ client, _ := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
456
+ body := requestBody(t, r)
457
+ var sent struct{ Model string }
458
+ _ = json.Unmarshal(body, &sent)
459
+ model = sent.Model
460
+ return responseFor(body), nil
461
+ })})
462
+ req := sampleRequest()
463
+ req.Model = c.requested
464
+ if _, err := client.Evaluate(context.Background(), req); err != nil || model != c.want {
465
+ t.Errorf("%+v: model=%s err=%v", c, model, err)
466
+ }
467
+ }
468
+ })
469
+ tw(t, "client", "a TypeSafe key is never sent to another backend", func(t *testing.T) {
470
+ isolate(t)
471
+ t.Setenv("TYPESAFE_API_KEY", "ts-test-key-1234567890123456")
472
+ _, err := New(Options{Backend: "openrouter"})
473
+ if !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "OPENROUTER_API_KEY") || strings.Contains(err.Error(), "typesafe login") {
474
+ t.Fatalf("err = %v", err)
475
+ }
476
+ })
477
+ tw(t, "client", "key resolution picks the right env var per backend", func(t *testing.T) {
478
+ isolate(t)
479
+ // With no key set, the openrouter backend should complain about OPENROUTER_API_KEY.
480
+ if _, err := New(Options{Backend: "openrouter"}); !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "OPENROUTER_API_KEY") {
481
+ t.Fatalf("err = %v", err)
482
+ }
483
+ // Setting the env var for the right backend gets past key resolution; construction sends nothing.
484
+ t.Setenv("OPENROUTER_API_KEY", "or-test-key-1234567890123456")
485
+ called := false
486
+ if _, err := New(Options{Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { called = true; return nil, nil })}); err != nil || called {
487
+ t.Fatalf("err = %v called=%v", err, called)
488
+ }
489
+ })
490
+ }