@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,142 @@
1
+ package typesafe
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ )
8
+
9
+ // Entry is text, a JSON object, a JSON array, or null: the value of a state, of a
10
+ // question's instructions, and of every criterion description (the TypeScript
11
+ // EntryType). The zero Entry is OMITTED: the field is left out of the request body,
12
+ // which is different from an explicit null that is sent as null.
13
+ //
14
+ // Build an Entry with [Text], [Value] or [Null], or let a builder convert a plain Go
15
+ // value with [EntryOf]. A JSON number or boolean is not an Entry; marshalling one
16
+ // fails with a *TypeSafeError.
17
+ type Entry struct {
18
+ present bool
19
+ value any
20
+ }
21
+
22
+ // Null is the explicit JSON null.
23
+ var Null = Entry{present: true}
24
+
25
+ // Text returns a text Entry.
26
+ func Text(s string) Entry { return Entry{present: true, value: s} }
27
+
28
+ // Value returns an Entry holding v: a map, slice, struct or json.RawMessage that
29
+ // marshals to a JSON object or array, a string, or nil (which is null). To keep key
30
+ // order, pass a json.RawMessage; Go maps marshal with sorted keys.
31
+ func Value(v any) Entry { return Entry{present: true, value: v} }
32
+
33
+ // EntryOf converts what a builder accepts: nil is [Null], an Entry is returned as is,
34
+ // anything else is [Value].
35
+ func EntryOf(v any) Entry {
36
+ switch x := v.(type) {
37
+ case nil:
38
+ return Null
39
+ case Entry:
40
+ return x
41
+ }
42
+ return Value(v)
43
+ }
44
+
45
+ // IsOmitted reports whether the Entry is the zero value (left out of the request).
46
+ func (e Entry) IsOmitted() bool { return !e.present }
47
+
48
+ // IsNull reports whether the Entry is an explicit null.
49
+ func (e Entry) IsNull() bool { return e.present && e.value == nil }
50
+
51
+ // Data returns the Go value of the Entry: string, map[string]any, []any, or nil for
52
+ // null and omitted.
53
+ func (e Entry) Data() any {
54
+ if !e.present || e.value == nil {
55
+ return nil
56
+ }
57
+ if s, ok := e.value.(string); ok {
58
+ return s
59
+ }
60
+ raw, err := e.MarshalJSON()
61
+ if err != nil {
62
+ return nil
63
+ }
64
+ var out any
65
+ if json.Unmarshal(raw, &out) != nil {
66
+ return nil
67
+ }
68
+ return out
69
+ }
70
+
71
+ // marshalPlain is json.Marshal without HTML escaping: the bodies then carry <, > and & as
72
+ // written, like the official SDK's JSON.stringify. U+2028 and U+2029 are always escaped
73
+ // (\u2028, \u2029; the receiving JSON parser decodes them the same): encoding/json escapes
74
+ // them in a string, but Go 1.26 copies them through raw from a Marshaler or json.RawMessage
75
+ // when HTML escaping is off, and Go 1.27 escapes those too. The raw bytes can only occur
76
+ // inside a JSON string, so replacing them keeps the body (and its Content-Length) the same
77
+ // on every toolchain.
78
+ func marshalPlain(v any) ([]byte, error) {
79
+ var b bytes.Buffer
80
+ enc := json.NewEncoder(&b)
81
+ enc.SetEscapeHTML(false)
82
+ if err := enc.Encode(v); err != nil {
83
+ return nil, err
84
+ }
85
+ return escapeLineSeparators(bytes.TrimSuffix(b.Bytes(), []byte("\n"))), nil
86
+ }
87
+
88
+ // escapeLineSeparators replaces raw U+2028 and U+2029 in encoded JSON with \u2028 and \u2029.
89
+ // Neither is common: a body that holds neither is returned as it is, not copied (bytes.ReplaceAll copies even
90
+ // when nothing matches).
91
+ func escapeLineSeparators(out []byte) []byte {
92
+ const ls, ps = "\u2028", "\u2029"
93
+ if bytes.Contains(out, []byte(ls)) {
94
+ out = bytes.ReplaceAll(out, []byte(ls), []byte(`\u2028`))
95
+ }
96
+ if bytes.Contains(out, []byte(ps)) {
97
+ out = bytes.ReplaceAll(out, []byte(ps), []byte(`\u2029`))
98
+ }
99
+ return out
100
+ }
101
+
102
+ // MarshalJSON encodes the Entry; an omitted Entry encodes as null (parents skip
103
+ // omitted fields themselves).
104
+ func (e Entry) MarshalJSON() ([]byte, error) {
105
+ if !e.present || e.value == nil {
106
+ return []byte("null"), nil
107
+ }
108
+ raw, err := marshalPlain(e.value)
109
+ if err != nil {
110
+ return nil, &TypeSafeError{Message: fmt.Sprintf("Cannot encode a description or instructions value: %v", err), Cause: err}
111
+ }
112
+ switch raw[0] {
113
+ case '"', '{', '[', 'n':
114
+ return raw, nil
115
+ }
116
+ return nil, &TypeSafeError{Message: fmt.Sprintf("A description, instructions or state must be text, a JSON object, a JSON array or null, got %s.", raw)}
117
+ }
118
+
119
+ // UnmarshalJSON decodes any JSON value; a decoded null is [Null], not omitted.
120
+ func (e *Entry) UnmarshalJSON(data []byte) error {
121
+ data = bytes.TrimSpace(data)
122
+ if len(data) == 0 {
123
+ return &TypeSafeError{Message: "Empty JSON value."}
124
+ }
125
+ switch data[0] {
126
+ case 'n':
127
+ *e = Null
128
+ case '"':
129
+ var s string
130
+ if err := json.Unmarshal(data, &s); err != nil {
131
+ return err
132
+ }
133
+ *e = Text(s)
134
+ case '{', '[':
135
+ *e = Value(json.RawMessage(append([]byte(nil), data...)))
136
+ default:
137
+ return &TypeSafeError{Message: fmt.Sprintf("A description, instructions or state must be text, a JSON object, a JSON array or null, got %s.", data)}
138
+ }
139
+ return nil
140
+ }
141
+
142
+ var _ json.Marshaler = Entry{}
@@ -0,0 +1,11 @@
1
+ package typesafe
2
+
3
+ // Environment variable names for client configuration. Explicit options take
4
+ // precedence over the environment, then the defaults. Empty or whitespace-only
5
+ // values are ignored.
6
+ const (
7
+ EnvAPIKey = "TYPESAFE_API_KEY"
8
+ EnvBaseURL = "TYPESAFE_BASE_URL"
9
+ EnvDefaultModel = "TYPESAFE_DEFAULT_MODEL"
10
+ EnvLogLevel = "TYPESAFE_LOG_LEVEL"
11
+ )
@@ -0,0 +1,310 @@
1
+ package typesafe
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "strconv"
9
+ "strings"
10
+ "time"
11
+ "unicode/utf16"
12
+ )
13
+
14
+ // TypeSafeError is the base of every error this package returns: configuration
15
+ // mistakes, invalid questions, API errors, connection failures and aborts. Every other
16
+ // error type unwraps to a *TypeSafeError, so
17
+ //
18
+ // var base *typesafe.TypeSafeError
19
+ // errors.As(err, &base)
20
+ //
21
+ // matches them all. The hierarchy of the TypeScript SDK maps to errors.As targets:
22
+ //
23
+ // TypeSafeError
24
+ // ├─ APIError (Status, Header, Body, RequestID)
25
+ // │ ├─ BadRequestError 400 ├─ AuthenticationError 401
26
+ // │ ├─ PermissionDeniedError 403 ├─ NotFoundError 404
27
+ // │ ├─ UnprocessableEntityError 422 ├─ RateLimitError 429 (RetryAfter)
28
+ // │ └─ InternalServerError >= 500 (any other status: a bare *APIError)
29
+ // ├─ APIConnectionError (Cause)
30
+ // │ └─ APITimeoutError (Timeout)
31
+ // └─ APIUserAbortError (Cause: the context error)
32
+ //
33
+ // Each subclass embeds a pointer to its parent and unwraps to it.
34
+ type TypeSafeError struct {
35
+ Message string
36
+ Cause error
37
+ }
38
+
39
+ func (e *TypeSafeError) Error() string { return e.Message }
40
+ func (e *TypeSafeError) Unwrap() error { return e.Cause }
41
+
42
+ func errorf(format string, args ...any) *TypeSafeError {
43
+ return &TypeSafeError{Message: fmt.Sprintf(format, args...)}
44
+ }
45
+
46
+ // APIError is an unsuccessful HTTP response from the API.
47
+ type APIError struct {
48
+ *TypeSafeError
49
+ // Status is the HTTP status code.
50
+ Status int
51
+ // Header holds the response headers.
52
+ Header http.Header
53
+ // Body is the parsed JSON (map[string]any, []any, float64, bool), the response text
54
+ // (string), or nil for an empty body.
55
+ Body any
56
+ // RequestID is the x-typesafe-request-id header, or "".
57
+ RequestID string
58
+ }
59
+
60
+ // requestIDHeader is the response header that carries the request ID.
61
+ const requestIDHeader = "x-typesafe-request-id"
62
+
63
+ // NewAPIError returns the error type for an HTTP status (the TypeScript
64
+ // APIError.fromResponse): a *BadRequestError for 400, and so on. The result's Error
65
+ // text is "<status> <detail>" where detail is extracted from the body (error string,
66
+ // error.message, message, detail string, detail.message, or FastAPI validation
67
+ // entries formatted as "loc: msg" joined by "; "), or the raw body truncated to 200
68
+ // characters plus "…", or "status code (no body)".
69
+ func NewAPIError(status int, body any, header http.Header) error {
70
+ return newAPIError(status, body, "", header)
71
+ }
72
+
73
+ // newAPIError is NewAPIError with the raw JSON text of the body, which keeps the key
74
+ // order of the server's answer in the message.
75
+ func newAPIError(status int, body any, rawJSON string, header http.Header) error {
76
+ if header == nil {
77
+ header = http.Header{}
78
+ }
79
+ base := &APIError{
80
+ TypeSafeError: &TypeSafeError{Message: describeAPIError(status, body, rawJSON)},
81
+ Status: status,
82
+ Header: header,
83
+ Body: body,
84
+ RequestID: header.Get(requestIDHeader),
85
+ }
86
+ switch {
87
+ case status == 400:
88
+ return &BadRequestError{base}
89
+ case status == 401:
90
+ return &AuthenticationError{base}
91
+ case status == 403:
92
+ return &PermissionDeniedError{base}
93
+ case status == 404:
94
+ return &NotFoundError{base}
95
+ case status == 422:
96
+ return &UnprocessableEntityError{base}
97
+ case status == 429:
98
+ e := &RateLimitError{APIError: base}
99
+ e.RetryAfter, e.HasRetryAfter = ParseRetryAfter(header, time.Now())
100
+ return e
101
+ case status >= 500:
102
+ return &InternalServerError{base}
103
+ }
104
+ return base
105
+ }
106
+
107
+ func (e *APIError) Error() string { return e.Message }
108
+ func (e *APIError) Unwrap() error { return e.TypeSafeError }
109
+
110
+ const maxRawBodyInMessage = 200
111
+
112
+ func describeAPIError(status int, body any, rawJSON string) string {
113
+ if detail := extractMessage(body); detail != "" {
114
+ return fmt.Sprintf("%d %s", status, detail)
115
+ }
116
+ if body == nil {
117
+ return fmt.Sprintf("%d status code (no body)", status)
118
+ }
119
+ raw := ""
120
+ if s, ok := body.(string); ok {
121
+ raw = s
122
+ } else if rawJSON != "" {
123
+ var buf bytes.Buffer
124
+ if json.Compact(&buf, []byte(rawJSON)) == nil {
125
+ raw = buf.String()
126
+ } else {
127
+ raw = rawJSON
128
+ }
129
+ } else if b, err := json.Marshal(body); err == nil {
130
+ raw = string(b)
131
+ }
132
+ units := utf16.Encode([]rune(raw))
133
+ if len(units) > maxRawBodyInMessage {
134
+ raw = string(utf16.Decode(units[:maxRawBodyInMessage])) + "…"
135
+ }
136
+ return fmt.Sprintf("%d %s", status, raw)
137
+ }
138
+
139
+ // extractMessage returns the message of a text, error, or validation response body.
140
+ func extractMessage(body any) string {
141
+ if s, ok := body.(string); ok {
142
+ return s
143
+ }
144
+ m, ok := body.(map[string]any)
145
+ if !ok {
146
+ return ""
147
+ }
148
+ if s, ok := m["error"].(string); ok {
149
+ return s
150
+ }
151
+ if e, ok := m["error"].(map[string]any); ok {
152
+ if s, ok := e["message"].(string); ok {
153
+ return s
154
+ }
155
+ }
156
+ if s, ok := m["message"].(string); ok {
157
+ return s
158
+ }
159
+ if s, ok := m["detail"].(string); ok {
160
+ return s
161
+ }
162
+ switch d := m["detail"].(type) {
163
+ case map[string]any:
164
+ if s, ok := d["message"].(string); ok {
165
+ return s
166
+ }
167
+ case []any:
168
+ return describeValidationErrors(d)
169
+ }
170
+ return ""
171
+ }
172
+
173
+ // describeValidationErrors formats validation errors as semicolon-separated "path: message" entries.
174
+ func describeValidationErrors(errs []any) string {
175
+ var parts []string
176
+ for _, e := range errs {
177
+ m, ok := e.(map[string]any)
178
+ if !ok {
179
+ continue
180
+ }
181
+ msg, ok := m["msg"].(string)
182
+ if !ok {
183
+ continue
184
+ }
185
+ var loc []string
186
+ if l, ok := m["loc"].([]any); ok {
187
+ for _, x := range l {
188
+ if s, isStr := x.(string); isStr && s == "body" {
189
+ continue
190
+ }
191
+ loc = append(loc, jsString(x))
192
+ }
193
+ }
194
+ if p := strings.Join(loc, "."); p != "" {
195
+ parts = append(parts, p+": "+msg)
196
+ } else {
197
+ parts = append(parts, msg)
198
+ }
199
+ }
200
+ return strings.Join(parts, "; ")
201
+ }
202
+
203
+ // jsString is JavaScript's String(x) for a decoded JSON scalar (array join semantics: null is empty).
204
+ func jsString(x any) string {
205
+ switch v := x.(type) {
206
+ case nil:
207
+ return ""
208
+ case string:
209
+ return v
210
+ case float64:
211
+ return strconv.FormatFloat(v, 'f', -1, 64)
212
+ case bool:
213
+ return strconv.FormatBool(v)
214
+ }
215
+ b, _ := json.Marshal(x)
216
+ return string(b)
217
+ }
218
+
219
+ // BadRequestError is HTTP 400.
220
+ type BadRequestError struct{ *APIError }
221
+
222
+ // AuthenticationError is HTTP 401.
223
+ type AuthenticationError struct{ *APIError }
224
+
225
+ // PermissionDeniedError is HTTP 403.
226
+ type PermissionDeniedError struct{ *APIError }
227
+
228
+ // NotFoundError is HTTP 404.
229
+ type NotFoundError struct{ *APIError }
230
+
231
+ // UnprocessableEntityError is HTTP 422.
232
+ type UnprocessableEntityError struct{ *APIError }
233
+
234
+ // InternalServerError is HTTP 5xx (500 and above).
235
+ type InternalServerError struct{ *APIError }
236
+
237
+ // RateLimitError is HTTP 429.
238
+ type RateLimitError struct {
239
+ *APIError
240
+ // RetryAfter is the server's retry delay from retry-after-ms or Retry-After;
241
+ // HasRetryAfter is false when the header is absent or invalid.
242
+ RetryAfter time.Duration
243
+ HasRetryAfter bool
244
+ }
245
+
246
+ func (e *BadRequestError) Unwrap() error { return e.APIError }
247
+ func (e *AuthenticationError) Unwrap() error { return e.APIError }
248
+ func (e *PermissionDeniedError) Unwrap() error { return e.APIError }
249
+ func (e *NotFoundError) Unwrap() error { return e.APIError }
250
+ func (e *UnprocessableEntityError) Unwrap() error { return e.APIError }
251
+ func (e *InternalServerError) Unwrap() error { return e.APIError }
252
+ func (e *RateLimitError) Unwrap() error { return e.APIError }
253
+
254
+ // APIConnectionError is a failed request or response-body delivery (DNS, TLS, closed
255
+ // connection, interrupted body). The message is "Connection error: <cause>".
256
+ type APIConnectionError struct {
257
+ *TypeSafeError
258
+ }
259
+
260
+ // APITimeoutError is a request whose full response did not arrive within the
261
+ // per-attempt timeout; it is also an *APIConnectionError. The message is
262
+ // "Request timed out after <n>ms."
263
+ type APITimeoutError struct {
264
+ *APIConnectionError
265
+ // TimeoutMs is the configured timeout in milliseconds.
266
+ TimeoutMs int64
267
+ }
268
+
269
+ // APIUserAbortError is a request cancelled through its context. Cause is the context
270
+ // error, so errors.Is(err, context.Canceled) holds. Message: "Request was aborted."
271
+ type APIUserAbortError struct {
272
+ *TypeSafeError
273
+ }
274
+
275
+ func (e *APIConnectionError) Unwrap() error { return e.TypeSafeError }
276
+ func (e *APITimeoutError) Unwrap() error { return e.APIConnectionError }
277
+ func (e *APIUserAbortError) Unwrap() error { return e.TypeSafeError }
278
+
279
+ func newConnectionError(cause error) *APIConnectionError {
280
+ msg := "Connection error."
281
+ if cause != nil {
282
+ msg = "Connection error: " + cause.Error()
283
+ }
284
+ return &APIConnectionError{&TypeSafeError{Message: msg, Cause: cause}}
285
+ }
286
+
287
+ func newTimeoutError(timeout time.Duration, cause error) *APITimeoutError {
288
+ ms := timeout.Milliseconds()
289
+ return &APITimeoutError{
290
+ APIConnectionError: &APIConnectionError{&TypeSafeError{Message: fmt.Sprintf("Request timed out after %dms.", ms), Cause: cause}},
291
+ TimeoutMs: ms,
292
+ }
293
+ }
294
+
295
+ func newAbortError(cause error) *APIUserAbortError {
296
+ return &APIUserAbortError{&TypeSafeError{Message: "Request was aborted.", Cause: cause}}
297
+ }
298
+
299
+ // NewConnectionError returns an *APIConnectionError with the SDK's message for cause.
300
+ // Sources of model or HTTP calls use it to report transport failures the retry policy
301
+ // recognizes.
302
+ func NewConnectionError(cause error) *APIConnectionError { return newConnectionError(cause) }
303
+
304
+ // NewTimeoutError returns an *APITimeoutError for a timeout that elapsed.
305
+ func NewTimeoutError(timeout time.Duration, cause error) *APITimeoutError {
306
+ return newTimeoutError(timeout, cause)
307
+ }
308
+
309
+ // NewAbortError returns an *APIUserAbortError carrying the context's cause.
310
+ func NewAbortError(cause error) *APIUserAbortError { return newAbortError(cause) }
@@ -0,0 +1,175 @@
1
+ package typesafe
2
+
3
+ import (
4
+ "errors"
5
+ "net/http"
6
+ "reflect"
7
+ "strings"
8
+ "testing"
9
+ "unicode/utf8"
10
+ )
11
+
12
+ // clientReturning returns a client whose every response is resp() with retries off.
13
+ func clientReturning(t *testing.T, resp func() *http.Response) *Client {
14
+ t.Helper()
15
+ return newClient(t, always(resp), func(c *Config) { c.Retry = RetryOverrides{MaxRetries: Ptr(0)} })
16
+ }
17
+
18
+ func TestAPIError_FromResponseMapsStatusesToTheirClasses(t *testing.T) {
19
+ twin(t,
20
+ "errors.test.ts | APIError.fromResponse maps 400 to [Function BadRequestError]",
21
+ "errors.test.ts | APIError.fromResponse maps 401 to [Function AuthenticationError]",
22
+ "errors.test.ts | APIError.fromResponse maps 403 to [Function PermissionDeniedError]",
23
+ "errors.test.ts | APIError.fromResponse maps 404 to [Function NotFoundError]",
24
+ "errors.test.ts | APIError.fromResponse maps 422 to [Function UnprocessableEntityError]",
25
+ "errors.test.ts | APIError.fromResponse maps 429 to [Function RateLimitError]",
26
+ "errors.test.ts | APIError.fromResponse maps 500 to [Function InternalServerError]",
27
+ "errors.test.ts | APIError.fromResponse maps 503 to [Function InternalServerError]",
28
+ "errors.test.ts | APIError.fromResponse maps 418 to [Function APIError]")
29
+ cases := []struct {
30
+ status int
31
+ class string
32
+ }{{400, "BadRequestError"}, {401, "AuthenticationError"}, {403, "PermissionDeniedError"}, {404, "NotFoundError"}, {422, "UnprocessableEntityError"}, {429, "RateLimitError"}, {500, "InternalServerError"}, {503, "InternalServerError"}, {418, "APIError"}}
33
+ for _, tc := range cases {
34
+ err := NewAPIError(tc.status, nil, http.Header{})
35
+ if got := reflect.TypeOf(err).Elem().Name(); got != tc.class {
36
+ t.Fatalf("%d: got %s, want %s", tc.status, got, tc.class)
37
+ }
38
+ api := mustAs[*APIError](t, err)
39
+ eq(t, api.Status, tc.status)
40
+ mustAs[*TypeSafeError](t, err)
41
+ if tc.class != "APIError" {
42
+ // Exactly its own class among the subclasses.
43
+ eq(t, errorClass(err), tc.class)
44
+ }
45
+ if err.Error() == "" {
46
+ t.Fatal("empty message")
47
+ }
48
+ }
49
+ }
50
+
51
+ // errorClass names the subclass an error is (not the parents it unwraps to).
52
+ func errorClass(err error) string {
53
+ switch err.(type) {
54
+ case *BadRequestError:
55
+ return "BadRequestError"
56
+ case *AuthenticationError:
57
+ return "AuthenticationError"
58
+ case *PermissionDeniedError:
59
+ return "PermissionDeniedError"
60
+ case *NotFoundError:
61
+ return "NotFoundError"
62
+ case *UnprocessableEntityError:
63
+ return "UnprocessableEntityError"
64
+ case *RateLimitError:
65
+ return "RateLimitError"
66
+ case *InternalServerError:
67
+ return "InternalServerError"
68
+ case *APIError:
69
+ return "APIError"
70
+ }
71
+ return "?"
72
+ }
73
+
74
+ func TestErrorMessagesAndBodies_UsesErrorMessageFromAJSONBodyAndExposesTheRequestID(t *testing.T) {
75
+ twin(t, "errors.test.ts | error messages and bodies uses error.message from a JSON body and exposes the request id")
76
+ c := clientReturning(t, func() *http.Response {
77
+ return jsonResp(401, map[string]any{"error": map[string]any{"message": "invalid api key"}}, "x-typesafe-request-id", "req_123")
78
+ })
79
+ _, err := c.Models().List(ctxBG(), nil)
80
+ mustAs[*AuthenticationError](t, err)
81
+ api := mustAs[*APIError](t, err)
82
+ eq(t, api.Error(), "401 invalid api key")
83
+ eq(t, api.RequestID, "req_123")
84
+ eq(t, api.Body, any(map[string]any{"error": map[string]any{"message": "invalid api key"}}))
85
+ eq(t, api.Header.Get("x-typesafe-request-id"), "req_123")
86
+ }
87
+
88
+ func TestErrorMessagesAndBodies_ExtractsAMessage(t *testing.T) {
89
+ twin(t,
90
+ "errors.test.ts | error messages and bodies extracts a message from { error: 'plain string' }",
91
+ "errors.test.ts | error messages and bodies extracts a message from { message: 'top-level message' }",
92
+ "errors.test.ts | error messages and bodies extracts a message from { detail: 'fastapi style' }",
93
+ "errors.test.ts | error messages and bodies extracts a message from { detail: { error_type: 'api_usage_error', message: 'Unknown model: x' } }",
94
+ "errors.test.ts | error messages and bodies extracts a message from { detail: [ { type: 'list_type', loc: [ 'body', 'questions', 'q', 'score', 'criteria' ], msg: 'Input should be a valid list' }, { type: 'too_short', loc: [ 'body', 'questions' ], msg: 'Dictionary should have at least 1 item' } ] }")
95
+ cases := []struct{ body, want string }{
96
+ {`{"error":"plain string"}`, "plain string"},
97
+ {`{"message":"top-level message"}`, "top-level message"},
98
+ {`{"detail":"fastapi style"}`, "fastapi style"},
99
+ {`{"detail":{"error_type":"api_usage_error","message":"Unknown model: x"}}`, "Unknown model: x"},
100
+ {`{"detail":[{"type":"list_type","loc":["body","questions","q","score","criteria"],"msg":"Input should be a valid list"},{"type":"too_short","loc":["body","questions"],"msg":"Dictionary should have at least 1 item"}]}`,
101
+ "questions.q.score.criteria: Input should be a valid list; questions: Dictionary should have at least 1 item"},
102
+ }
103
+ for _, tc := range cases {
104
+ c := clientReturning(t, func() *http.Response { return jsonResp(400, decode(t, tc.body)) })
105
+ _, err := c.Models().List(ctxBG(), nil)
106
+ eq(t, err.Error(), "400 "+tc.want)
107
+ }
108
+ }
109
+
110
+ func TestErrorMessagesAndBodies_FallsBackToTheRawBodyWhenNoMessageCanBeExtractedTruncated(t *testing.T) {
111
+ twin(t, "errors.test.ts | error messages and bodies falls back to the raw body when no message can be extracted, truncated")
112
+ c := clientReturning(t, func() *http.Response { return jsonResp(400, map[string]any{"code": 7}) })
113
+ _, err := c.Models().List(ctxBG(), nil)
114
+ eq(t, err.Error(), `400 {"code":7}`)
115
+ c = clientReturning(t, func() *http.Response { return jsonResp(400, map[string]any{"blob": strings.Repeat("x", 500)}) })
116
+ _, err = c.Models().List(ctxBG(), nil)
117
+ eq(t, utf8.RuneCountInString(err.Error()), len("400 ")+200+1)
118
+ if !strings.HasSuffix(err.Error(), "…") {
119
+ t.Fatalf("not truncated with an ellipsis: %s", err)
120
+ }
121
+ }
122
+
123
+ func TestErrorMessagesAndBodies_KeepsNonJSONBodiesAsText(t *testing.T) {
124
+ twin(t, "errors.test.ts | error messages and bodies keeps non-JSON bodies as text")
125
+ c := clientReturning(t, func() *http.Response { return textResp(502, "<h1>bad gateway</h1>", "content-type", "text/html") })
126
+ _, err := c.Models().List(ctxBG(), nil)
127
+ mustAs[*InternalServerError](t, err)
128
+ eq(t, mustAs[*APIError](t, err).Body, any("<h1>bad gateway</h1>"))
129
+ eq(t, err.Error(), "502 <h1>bad gateway</h1>")
130
+ }
131
+
132
+ func TestErrorMessagesAndBodies_HandlesEmptyBodies(t *testing.T) {
133
+ twin(t, "errors.test.ts | error messages and bodies handles empty bodies")
134
+ c := clientReturning(t, func() *http.Response { return textResp(429, "") })
135
+ _, err := c.Models().List(ctxBG(), nil)
136
+ mustAs[*RateLimitError](t, err)
137
+ if mustAs[*APIError](t, err).Body != nil {
138
+ t.Fatal("an empty body is nil")
139
+ }
140
+ eq(t, err.Error(), "429 status code (no body)")
141
+ }
142
+
143
+ func TestErrorMessagesAndBodies_ParsesJSONEvenWhenContentTypeIsMissing(t *testing.T) {
144
+ twin(t, "errors.test.ts | error messages and bodies parses JSON even when content-type is missing")
145
+ c := clientReturning(t, func() *http.Response { return textResp(400, `{"message":"no content type"}`) })
146
+ _, err := c.Models().List(ctxBG(), nil)
147
+ eq(t, mustAs[*APIError](t, err).Body, any(map[string]any{"message": "no content type"}))
148
+ }
149
+
150
+ // Go-specific cases beyond the upstream suite.
151
+
152
+ func TestAPIError_SubclassesUnwrapToTheirParents(t *testing.T) {
153
+ var err error = NewAPIError(404, nil, http.Header{})
154
+ mustAs[*NotFoundError](t, err)
155
+ mustAs[*APIError](t, err)
156
+ mustAs[*TypeSafeError](t, err)
157
+ notAs[*BadRequestError](t, err)
158
+ notAs[*APIConnectionError](t, err)
159
+ conn := &APITimeoutError{APIConnectionError: &APIConnectionError{TypeSafeError: &TypeSafeError{Message: "Request timed out after 5ms."}}, TimeoutMs: 5}
160
+ mustAs[*APITimeoutError](t, conn)
161
+ mustAs[*APIConnectionError](t, conn)
162
+ mustAs[*TypeSafeError](t, conn)
163
+ notAs[*APIError](t, conn)
164
+ abort := &APIUserAbortError{TypeSafeError: &TypeSafeError{Message: "Request was aborted.", Cause: errors.New("c")}}
165
+ mustAs[*TypeSafeError](t, abort)
166
+ notAs[*APIConnectionError](t, abort)
167
+ }
168
+
169
+ func TestAPIError_RawBodyKeepsKeyOrderInTheMessage(t *testing.T) {
170
+ c := clientReturning(t, func() *http.Response {
171
+ return textResp(400, `{ "b": 1, "a": [1, 2] }`, "content-type", "application/json")
172
+ })
173
+ _, err := c.Models().List(ctxBG(), nil)
174
+ eq(t, err.Error(), `400 {"b":1,"a":[1,2]}`)
175
+ }