@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,561 @@
1
+ package typesafe
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "os"
12
+ "strings"
13
+ "sync/atomic"
14
+ "time"
15
+ )
16
+
17
+ // HTTPDoer is the HTTP transport (the TypeScript `fetch` option); *http.Client
18
+ // satisfies it. The request carries the attempt's context.
19
+ type HTTPDoer interface {
20
+ Do(req *http.Request) (*http.Response, error)
21
+ }
22
+
23
+ // Config configures a [Client]. Explicit values take precedence over environment
24
+ // variables, then the defaults; a zero value means "not set".
25
+ type Config struct {
26
+ // APIKey is required; falls back to TYPESAFE_API_KEY.
27
+ APIKey string
28
+ // BaseURL is the API root; falls back to TYPESAFE_BASE_URL, then DefaultBaseURL.
29
+ // Trailing slashes are removed.
30
+ BaseURL string
31
+ // DefaultModel falls back to TYPESAFE_DEFAULT_MODEL, then DefaultModel.
32
+ DefaultModel string
33
+ // LogLevel falls back to TYPESAFE_LOG_LEVEL, then DefaultLogLevel.
34
+ LogLevel LogLevel
35
+ // Logger is filtered to LogLevel and above; nil uses [NewStderrLogger].
36
+ Logger Logger
37
+ // Retry overrides the retry defaults field by field.
38
+ Retry RetryOverrides
39
+ // Timeout is per attempt (there is no total retry budget); zero means DefaultTimeout,
40
+ // a negative value is an error.
41
+ Timeout time.Duration
42
+ // DefaultHeaders are sent with every request; per-call headers take precedence.
43
+ // The SDK's own headers (Authorization, Content-Type, Accept, User-Agent,
44
+ // X-TypeSafe-*) cannot be replaced.
45
+ DefaultHeaders map[string]string
46
+ // HTTPClient is the transport; nil uses a client with no overall timeout.
47
+ HTTPClient HTTPDoer
48
+ // Getenv reads the environment; nil uses os.Getenv.
49
+ Getenv func(string) string
50
+ }
51
+
52
+ // RequestOptions are per-call overrides. Cancellation and deadlines come from the
53
+ // context passed to the call.
54
+ type RequestOptions struct {
55
+ // Timeout per attempt; zero inherits the client's, a negative value is an error.
56
+ Timeout time.Duration
57
+ // Retry overrides the client's policy for this call, field by field.
58
+ Retry RetryOverrides
59
+ // Headers are merged over the client's default headers.
60
+ Headers map[string]string
61
+ }
62
+
63
+ // Evaluator answers typed questions. [Client] (the TypeSafe API) and the own-model
64
+ // backend implement it; code that only needs answers should depend on this interface.
65
+ type Evaluator interface {
66
+ SystemOne(ctx context.Context, req SystemOneRequest, opts *RequestOptions) (*SystemOneResult, error)
67
+ }
68
+
69
+ // Client is a client for the TypeSafe AI API. It is safe for concurrent use. Its
70
+ // String, GoString and Format methods never reveal the API key.
71
+ type Client struct {
72
+ // apiKey is a closure so that no formatting of the struct can print the key.
73
+ apiKey func() string
74
+ baseURL string
75
+ defaultModel string
76
+ logLevel LogLevel
77
+ logger Logger
78
+ retry RetryPolicy
79
+ timeout time.Duration
80
+ defaultHeaders map[string]string
81
+ doer HTTPDoer
82
+ models *Models
83
+ requestCount atomic.Int64
84
+
85
+ // random supplies jitter in [0,1); tests replace it. nil uses math/rand.
86
+ random func() float64
87
+ }
88
+
89
+ var _ Evaluator = (*Client)(nil)
90
+
91
+ func readEnv(getenv func(string) string, name string) string { return strings.TrimSpace(getenv(name)) }
92
+
93
+ // NewClient validates the configuration and returns a client. It returns a
94
+ // *TypeSafeError when the API key is missing (naming TYPESAFE_API_KEY), the log level
95
+ // is invalid, or a timeout or retry field is out of range.
96
+ func NewClient(cfg Config) (*Client, error) {
97
+ getenv := cfg.Getenv
98
+ if getenv == nil {
99
+ getenv = os.Getenv
100
+ }
101
+ fromConfigOrEnv := func(explicit, env string) string {
102
+ if explicit != "" {
103
+ return explicit
104
+ }
105
+ return readEnv(getenv, env)
106
+ }
107
+ key := fromConfigOrEnv(cfg.APIKey, EnvAPIKey)
108
+ if key == "" {
109
+ return nil, errorf("No API key was provided. Set `APIKey` in the Config passed to NewClient, or set the %s environment variable.", EnvAPIKey)
110
+ }
111
+ baseURL := fromConfigOrEnv(cfg.BaseURL, EnvBaseURL)
112
+ if baseURL == "" {
113
+ baseURL = DefaultBaseURL
114
+ }
115
+ baseURL = strings.TrimRight(baseURL, "/")
116
+ model := fromConfigOrEnv(cfg.DefaultModel, EnvDefaultModel)
117
+ if model == "" {
118
+ model = DefaultModel
119
+ }
120
+ level := DefaultLogLevel
121
+ var err error
122
+ if cfg.LogLevel != "" {
123
+ level, err = ParseLogLevel(string(cfg.LogLevel), "the `LogLevel` option")
124
+ } else if env := readEnv(getenv, EnvLogLevel); env != "" {
125
+ level, err = ParseLogLevel(env, EnvLogLevel)
126
+ }
127
+ if err != nil {
128
+ return nil, err
129
+ }
130
+ sink := cfg.Logger
131
+ if sink == nil {
132
+ sink = NewStderrLogger()
133
+ }
134
+ retry, err := DefaultRetryPolicy().Resolve(cfg.Retry)
135
+ if err != nil {
136
+ return nil, err
137
+ }
138
+ timeout, err := resolveTimeout("Timeout", cfg.Timeout, DefaultTimeout)
139
+ if err != nil {
140
+ return nil, err
141
+ }
142
+ headers := make(map[string]string, len(cfg.DefaultHeaders))
143
+ for k, v := range cfg.DefaultHeaders {
144
+ headers[k] = v
145
+ }
146
+ doer := cfg.HTTPClient
147
+ if doer == nil {
148
+ doer = &http.Client{}
149
+ }
150
+ c := &Client{
151
+ apiKey: func() string { return key },
152
+ baseURL: baseURL,
153
+ defaultModel: model,
154
+ logLevel: level,
155
+ logger: WithLevel(sink, level),
156
+ retry: retry,
157
+ timeout: timeout,
158
+ defaultHeaders: headers,
159
+ doer: doer,
160
+ }
161
+ c.models = &Models{c: c}
162
+ return c, nil
163
+ }
164
+
165
+ // resolveTimeout applies the zero-means-inherit rule and rejects negative values.
166
+ func resolveTimeout(name string, v, inherit time.Duration) (time.Duration, error) {
167
+ if v < 0 {
168
+ return 0, errorf("`%s` must be a positive duration, got %s.", name, v)
169
+ }
170
+ if v == 0 {
171
+ return inherit, nil
172
+ }
173
+ return v, nil
174
+ }
175
+
176
+ // BaseURL returns the API root without trailing slashes.
177
+ func (c *Client) BaseURL() string { return c.baseURL }
178
+
179
+ // DefaultModel returns the model used when a request omits one.
180
+ func (c *Client) DefaultModel() string { return c.defaultModel }
181
+
182
+ // LogLevel returns the configured verbosity.
183
+ func (c *Client) LogLevel() LogLevel { return c.logLevel }
184
+
185
+ // Logger returns the configured logger, filtered to the log level.
186
+ func (c *Client) Logger() Logger { return c.logger }
187
+
188
+ // RetryPolicy returns a copy of the resolved retry policy.
189
+ func (c *Client) RetryPolicy() RetryPolicy {
190
+ p, _ := c.retry.Resolve(RetryOverrides{})
191
+ return p
192
+ }
193
+
194
+ // Timeout returns the per-attempt timeout.
195
+ func (c *Client) Timeout() time.Duration { return c.timeout }
196
+
197
+ // DefaultHeaders returns a copy of the additional default headers.
198
+ func (c *Client) DefaultHeaders() map[string]string {
199
+ out := make(map[string]string, len(c.defaultHeaders))
200
+ for k, v := range c.defaultHeaders {
201
+ out[k] = v
202
+ }
203
+ return out
204
+ }
205
+
206
+ // String returns a description without the API key.
207
+ func (c *Client) String() string {
208
+ return fmt.Sprintf("typesafe.Client{BaseURL: %s, DefaultModel: %s, LogLevel: %s}", c.baseURL, c.defaultModel, c.logLevel)
209
+ }
210
+
211
+ // GoString returns a description without the API key.
212
+ func (c *Client) GoString() string { return c.String() }
213
+
214
+ // Models returns the Models resource.
215
+ func (c *Client) Models() *Models { return c.models }
216
+
217
+ // SystemOne answers named questions about text or structured state. Questions are
218
+ // validated before anything is sent (*TypeSafeError). It returns an *APIError
219
+ // subclass for a non-2xx response after retries, an *APIConnectionError (or
220
+ // *APITimeoutError) for transport failures after retries, and an *APIUserAbortError
221
+ // when ctx ends.
222
+ func (c *Client) SystemOne(ctx context.Context, req SystemOneRequest, opts *RequestOptions) (*SystemOneResult, error) {
223
+ res, err := c.SystemOneWithResponse(ctx, req, opts)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+ return res.Data, nil
228
+ }
229
+
230
+ // Response is a parsed result with the HTTP response metadata.
231
+ type Response[T any] struct {
232
+ Data T
233
+ RawResponse
234
+ }
235
+
236
+ // RawResponse is the buffered HTTP response of a successful request.
237
+ type RawResponse struct {
238
+ Status int
239
+ Header http.Header
240
+ // Body is the complete response body (buffered under the attempt timeout).
241
+ Body []byte
242
+ // URL is the requested URL.
243
+ URL string
244
+ // RequestID is the x-typesafe-request-id header, or "".
245
+ RequestID string
246
+
247
+ tag string // the log tag of the request
248
+ }
249
+
250
+ // logBody logs a parsed response body at debug level, like the TypeScript parse step.
251
+ func (c *Client) logBody(raw *RawResponse) {
252
+ if c.logLevel != LogDebug {
253
+ return // the logger drops it; do not parse the body to build it
254
+ }
255
+ parsed, _ := parseBody(raw.Body)
256
+ c.logger.Debug(raw.tag+" <- body", parsed)
257
+ }
258
+
259
+ func (c *Client) systemOnePayload(req SystemOneRequest) ([]byte, error) {
260
+ if err := req.Questions.Validate(); err != nil {
261
+ return nil, err
262
+ }
263
+ if req.Model == "" {
264
+ req.Model = c.defaultModel
265
+ }
266
+ body, err := marshalPlain(req)
267
+ if err != nil {
268
+ return nil, unwrapMarshalError(err)
269
+ }
270
+ return body, nil
271
+ }
272
+
273
+ // SystemOneWithResponse is [Client.SystemOne] with the response metadata.
274
+ func (c *Client) SystemOneWithResponse(ctx context.Context, req SystemOneRequest, opts *RequestOptions) (*Response[*SystemOneResult], error) {
275
+ body, err := c.systemOnePayload(req)
276
+ if err != nil {
277
+ return nil, err
278
+ }
279
+ raw, err := c.request(ctx, http.MethodPost, "/v1/systemone", body, opts)
280
+ if err != nil {
281
+ return nil, err
282
+ }
283
+ c.logBody(raw)
284
+ result := &SystemOneResult{}
285
+ if len(bytes.TrimSpace(raw.Body)) > 0 {
286
+ if err := json.Unmarshal(raw.Body, result); err != nil {
287
+ return nil, &TypeSafeError{Message: "Unexpected response shape from POST /v1/systemone; expected { model, answers, usage }.", Cause: err}
288
+ }
289
+ }
290
+ return &Response[*SystemOneResult]{Data: result, RawResponse: *raw}, nil
291
+ }
292
+
293
+ // SystemOneRaw sends the request and returns the response without parsing its body
294
+ // (the TypeScript asResponse()). A non-2xx status is still an *APIError.
295
+ func (c *Client) SystemOneRaw(ctx context.Context, req SystemOneRequest, opts *RequestOptions) (*RawResponse, error) {
296
+ body, err := c.systemOnePayload(req)
297
+ if err != nil {
298
+ return nil, err
299
+ }
300
+ return c.request(ctx, http.MethodPost, "/v1/systemone", body, opts)
301
+ }
302
+
303
+ // Models is the Models API resource.
304
+ type Models struct {
305
+ c *Client
306
+ }
307
+
308
+ // List lists the models available to the account (GET /v1/models). A body that is not
309
+ // {"models": [...]} is a *TypeSafeError "Unexpected response shape from GET /v1/models; expected { models: [...] }."
310
+ func (m *Models) List(ctx context.Context, opts *RequestOptions) ([]ModelCard, error) {
311
+ res, err := m.ListWithResponse(ctx, opts)
312
+ if err != nil {
313
+ return nil, err
314
+ }
315
+ return res.Data, nil
316
+ }
317
+
318
+ // ListWithResponse is [Models.List] with the response metadata.
319
+ func (m *Models) ListWithResponse(ctx context.Context, opts *RequestOptions) (*Response[[]ModelCard], error) {
320
+ raw, err := m.c.request(ctx, http.MethodGet, "/v1/models", nil, opts)
321
+ if err != nil {
322
+ return nil, err
323
+ }
324
+ m.c.logBody(raw)
325
+ shapeErr := &TypeSafeError{Message: "Unexpected response shape from GET /v1/models; expected { models: [...] }."}
326
+ var wire map[string]json.RawMessage
327
+ if err := json.Unmarshal(raw.Body, &wire); err != nil || wire == nil {
328
+ return nil, shapeErr
329
+ }
330
+ list := bytes.TrimSpace(wire["models"])
331
+ if len(list) == 0 || list[0] != '[' {
332
+ return nil, shapeErr
333
+ }
334
+ cards := []ModelCard{}
335
+ if err := json.Unmarshal(list, &cards); err != nil {
336
+ return nil, &TypeSafeError{Message: shapeErr.Message, Cause: err}
337
+ }
338
+ return &Response[[]ModelCard]{Data: cards, RawResponse: *raw}, nil
339
+ }
340
+
341
+ // ListRaw returns the unparsed response, which keeps fields the typed card drops.
342
+ func (m *Models) ListRaw(ctx context.Context, opts *RequestOptions) (*RawResponse, error) {
343
+ return m.c.request(ctx, http.MethodGet, "/v1/models", nil, opts)
344
+ }
345
+
346
+ // MapResponse transforms the data of a response and keeps its metadata.
347
+ func MapResponse[T, U any](r *Response[T], fn func(T) U) *Response[U] {
348
+ return &Response[U]{Data: fn(r.Data), RawResponse: r.RawResponse}
349
+ }
350
+
351
+ // header is one request header in the spelling the SDK uses.
352
+ type header struct{ name, value string }
353
+
354
+ // mergeHeaders merges sources in order: a later value wins regardless of the name's
355
+ // casing, and an entry with remove set deletes a protected header.
356
+ func mergeHeaders(sources ...[]headerEdit) []header {
357
+ var out []header
358
+ index := map[string]int{}
359
+ for _, src := range sources {
360
+ for _, e := range src {
361
+ lower := strings.ToLower(e.name)
362
+ i, exists := index[lower]
363
+ switch {
364
+ case e.remove && exists:
365
+ out[i].name = ""
366
+ delete(index, lower)
367
+ case e.remove:
368
+ case exists:
369
+ out[i] = header{e.name, e.value}
370
+ default:
371
+ index[lower] = len(out)
372
+ out = append(out, header{e.name, e.value})
373
+ }
374
+ }
375
+ }
376
+ kept := out[:0]
377
+ for _, h := range out {
378
+ if h.name != "" {
379
+ kept = append(kept, h)
380
+ }
381
+ }
382
+ return kept
383
+ }
384
+
385
+ type headerEdit struct {
386
+ name, value string
387
+ remove bool
388
+ }
389
+
390
+ func editsOf(m map[string]string) []headerEdit {
391
+ out := make([]headerEdit, 0, len(m))
392
+ for k, v := range m {
393
+ out = append(out, headerEdit{name: k, value: v})
394
+ }
395
+ return out
396
+ }
397
+
398
+ // request sends one API request with retries and returns its buffered response.
399
+ func (c *Client) request(ctx context.Context, method, path string, body []byte, opts *RequestOptions) (*RawResponse, error) {
400
+ if opts == nil {
401
+ opts = &RequestOptions{}
402
+ }
403
+ timeout, err := resolveTimeout("Timeout", opts.Timeout, c.timeout)
404
+ if err != nil {
405
+ return nil, err
406
+ }
407
+ policy, err := c.retry.Resolve(opts.Retry)
408
+ if err != nil {
409
+ return nil, err
410
+ }
411
+ // Numbered so concurrent requests, and the attempts within one, can be told apart in the logs.
412
+ tag := fmt.Sprintf("#%d %s %s", c.requestCount.Add(1), method, path)
413
+ url := c.baseURL + path
414
+ // User-supplied headers go first so they cannot clobber auth or the JSON content type.
415
+ protected := []headerEdit{
416
+ {name: "Authorization", value: "Bearer " + c.apiKey()},
417
+ {name: "Accept", value: "application/json"},
418
+ {name: "User-Agent", value: "typesafe-sdk-go/" + Version},
419
+ {name: "X-TypeSafe-SDK", value: "typesafe-sdk-go/" + Version},
420
+ {name: "X-TypeSafe-Runtime", value: describeRuntime()},
421
+ {name: "X-TypeSafe-Retry-Count", remove: true},
422
+ }
423
+ if body == nil {
424
+ protected = append(protected, headerEdit{name: "Content-Type", remove: true})
425
+ } else {
426
+ protected = append(protected, headerEdit{name: "Content-Type", value: "application/json"})
427
+ }
428
+ base := mergeHeaders(editsOf(c.defaultHeaders), editsOf(opts.Headers), protected)
429
+
430
+ hooks := RetryHooks{
431
+ Random: c.random,
432
+ OnRetry: func(retry, total int, delay time.Duration, reason string) {
433
+ c.logger.Info(fmt.Sprintf("%s retrying in %dms (retry %d/%d) after %s", tag, delay.Milliseconds(), retry, total, reason))
434
+ },
435
+ OnAbortDuringWait: func() { c.logger.Info(tag + " aborted by caller while waiting to retry") },
436
+ }
437
+ return Retry(ctx, policy, hooks, func(ctx context.Context, attempt int) (*RawResponse, error) {
438
+ headers := base
439
+ if attempt > 0 {
440
+ headers = append(append([]header(nil), base...), header{"X-TypeSafe-Retry-Count", fmt.Sprint(attempt)})
441
+ }
442
+ if c.logLevel == LogDebug { // only debug prints headers and body; the other levels would drop them
443
+ logged := make(map[string]string, len(headers))
444
+ for _, h := range headers {
445
+ logged[h.name] = h.value
446
+ }
447
+ var bodyLog any
448
+ if body != nil {
449
+ bodyLog = json.RawMessage(body)
450
+ }
451
+ c.logger.Debug(fmt.Sprintf("%s -> %s", tag, url), map[string]any{"headers": RedactHeaders(logged), "body": bodyLog})
452
+ }
453
+
454
+ started := time.Now()
455
+ raw, err := c.attempt(ctx, tag, method, url, headers, body, timeout, started)
456
+ if err != nil {
457
+ return nil, err
458
+ }
459
+ requestID := ""
460
+ if raw.RequestID != "" {
461
+ requestID = fmt.Sprintf(" (request %s)", raw.RequestID)
462
+ }
463
+ c.logger.Info(fmt.Sprintf("%s <- %d in %dms%s", tag, raw.Status, time.Since(started).Milliseconds(), requestID))
464
+ if raw.Status >= 200 && raw.Status < 300 {
465
+ return raw, nil
466
+ }
467
+ parsed, text := parseBody(raw.Body)
468
+ c.logger.Debug(tag+" <- error body", parsed)
469
+ return nil, newAPIError(raw.Status, parsed, text, raw.Header)
470
+ })
471
+ }
472
+
473
+ // parseBody parses a response body leniently: JSON when it is JSON (whatever the
474
+ // content type), else the text; nil for an empty body. The second value is the raw JSON
475
+ // text when the body parsed.
476
+ func parseBody(body []byte) (parsed any, rawJSON string) {
477
+ if len(body) == 0 {
478
+ return nil, ""
479
+ }
480
+ var v any
481
+ if err := json.Unmarshal(body, &v); err != nil {
482
+ return string(body), ""
483
+ }
484
+ return v, string(body)
485
+ }
486
+
487
+ // attempt is one HTTP round trip including body delivery, under the attempt timeout. The
488
+ // caller's context and the timer are told apart to choose the error class.
489
+ func (c *Client) attempt(ctx context.Context, tag, method, url string, headers []header, body []byte, timeout time.Duration, started time.Time) (*RawResponse, error) {
490
+ actx, cancel := context.WithTimeout(ctx, timeout)
491
+ defer cancel()
492
+ elapsed := func() string { return fmt.Sprintf("%dms", time.Since(started).Milliseconds()) }
493
+ fail := func(cause error) error {
494
+ if ctx.Err() != nil {
495
+ c.logger.Info(fmt.Sprintf("%s aborted by caller after %s", tag, elapsed()))
496
+ return newAbortError(context.Cause(ctx))
497
+ }
498
+ if errors.Is(actx.Err(), context.DeadlineExceeded) {
499
+ c.logger.Info(fmt.Sprintf("%s timed out after %s", tag, elapsed()))
500
+ return newTimeoutError(timeout, cause)
501
+ }
502
+ c.logger.Info(fmt.Sprintf("%s connection error after %s", tag, elapsed()), cause)
503
+ return newConnectionError(cause)
504
+ }
505
+
506
+ var reader io.Reader
507
+ if body != nil {
508
+ reader = bytes.NewReader(body)
509
+ }
510
+ req, err := http.NewRequestWithContext(actx, method, url, reader)
511
+ if err != nil {
512
+ return nil, fail(err)
513
+ }
514
+ for _, h := range headers {
515
+ req.Header.Set(h.name, h.value)
516
+ }
517
+ resp, err := c.doer.Do(req)
518
+ if err != nil {
519
+ return nil, fail(err)
520
+ }
521
+ data, err := readBody(actx, resp.Body)
522
+ if err != nil {
523
+ return nil, fail(err)
524
+ }
525
+ if ctx.Err() != nil { // the caller gave up while the body was arriving
526
+ return nil, fail(context.Cause(ctx))
527
+ }
528
+ return &RawResponse{
529
+ Status: resp.StatusCode,
530
+ Header: resp.Header.Clone(),
531
+ Body: data,
532
+ URL: url,
533
+ RequestID: resp.Header.Get(requestIDHeader),
534
+ tag: tag,
535
+ }, nil
536
+ }
537
+
538
+ // readBody reads and closes body, giving up when ctx ends even if the body ignores
539
+ // cancellation (a transport that does not honor the request's context).
540
+ func readBody(ctx context.Context, body io.ReadCloser) ([]byte, error) {
541
+ if body == nil {
542
+ return nil, nil
543
+ }
544
+ type result struct {
545
+ data []byte
546
+ err error
547
+ }
548
+ done := make(chan result, 1)
549
+ go func() {
550
+ data, err := io.ReadAll(body)
551
+ done <- result{data, err}
552
+ }()
553
+ select {
554
+ case r := <-done:
555
+ _ = body.Close()
556
+ return r.data, r.err
557
+ case <-ctx.Done():
558
+ _ = body.Close()
559
+ return nil, context.Cause(ctx)
560
+ }
561
+ }