@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.
- package/CREDITS.md +22 -0
- package/LICENSE +22 -0
- package/README.md +237 -0
- package/extensions/jev/ask.go +166 -0
- package/extensions/jev/ask_test.go +218 -0
- package/extensions/jev/backend.go +128 -0
- package/extensions/jev/bench_test.go +64 -0
- package/extensions/jev/boundaries_test.go +159 -0
- package/extensions/jev/command.go +224 -0
- package/extensions/jev/commands_test.go +214 -0
- package/extensions/jev/config.go +450 -0
- package/extensions/jev/errors_test.go +191 -0
- package/extensions/jev/extension.go +391 -0
- package/extensions/jev/fakehost_test.go +548 -0
- package/extensions/jev/gate.go +125 -0
- package/extensions/jev/gate_test.go +610 -0
- package/extensions/jev/gatekey_test.go +24 -0
- package/extensions/jev/go.mod +9 -0
- package/extensions/jev/go.sum +2 -0
- package/extensions/jev/go.work +10 -0
- package/extensions/jev/helpers_test.go +404 -0
- package/extensions/jev/memo.go +88 -0
- package/extensions/jev/output.go +89 -0
- package/extensions/jev/output_test.go +187 -0
- package/extensions/jev/ownmodel_test.go +118 -0
- package/extensions/jev/render.go +136 -0
- package/extensions/jev/review_test.go +310 -0
- package/extensions/jev/source_test.go +57 -0
- package/extensions/jev/text.go +174 -0
- package/extensions/jev/trust_test.go +335 -0
- package/extensions/jev/types.go +227 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +107 -0
- package/port/e2e/gate-and-output.py +35 -0
- package/port/e2e/jev-ask.py +36 -0
- package/port/e2e/model-switch.py +44 -0
- package/port/e2e/off-by-default.py +34 -0
- package/port/gen-scenarios.py +103 -0
- package/port/golden/cache-identical-calls.jsonl +30 -0
- package/port/golden/clear.jsonl +22 -0
- package/port/golden/commands.jsonl +43 -0
- package/port/golden/enforce-accept.jsonl +23 -0
- package/port/golden/enforce-decline.jsonl +22 -0
- package/port/golden/jev-ask.jsonl +20 -0
- package/port/golden/output-advice.jsonl +23 -0
- package/port/golden/output-leak.jsonl +24 -0
- package/port/golden/output-low-confidence.jsonl +22 -0
- package/port/golden/shadow-flagged.jsonl +23 -0
- package/port/golden/unjudged-tools.jsonl +19 -0
- package/port/golden/write-elision.jsonl +21 -0
- package/port/mutate-unit.py +63 -0
- package/port/mutations.json +578 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +181 -0
- package/port/oracle/SHA256SUMS +8 -0
- package/port/oracle/package.json +43 -0
- package/port/oracle/src/client.ts +409 -0
- package/port/oracle/src/config.ts +363 -0
- package/port/oracle/src/gate.ts +229 -0
- package/port/oracle/src/index.ts +649 -0
- package/port/oracle/src/output.ts +163 -0
- package/port/red-run.log +309 -0
- package/port/scenarios/cache-identical-calls.json +71 -0
- package/port/scenarios/clear.json +61 -0
- package/port/scenarios/commands.json +119 -0
- package/port/scenarios/enforce-accept.json +66 -0
- package/port/scenarios/enforce-decline.json +57 -0
- package/port/scenarios/jev-ask.json +83 -0
- package/port/scenarios/output-advice.json +61 -0
- package/port/scenarios/output-leak.json +61 -0
- package/port/scenarios/output-low-confidence.json +61 -0
- package/port/scenarios/shadow-flagged.json +61 -0
- package/port/scenarios/unjudged-tools.json +55 -0
- package/port/scenarios/write-elision.json +53 -0
- 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
|
+
}
|