@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,495 @@
|
|
|
1
|
+
package typesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"reflect"
|
|
10
|
+
"regexp"
|
|
11
|
+
"runtime"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// sentAPIKey returns the API key the client puts on an outgoing request.
|
|
18
|
+
func sentAPIKey(t *testing.T, cfg Config) string {
|
|
19
|
+
t.Helper()
|
|
20
|
+
m := always(func() *http.Response { return jsonResp(200, map[string]any{"models": []any{}}) })
|
|
21
|
+
cfg.HTTPClient = m
|
|
22
|
+
c, err := NewClient(cfg)
|
|
23
|
+
noErr(t, err)
|
|
24
|
+
_, err = c.Models().List(ctxBG(), nil)
|
|
25
|
+
noErr(t, err)
|
|
26
|
+
return strings.TrimPrefix(m.req(0).Header.Get("Authorization"), "Bearer ")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// twin: client.test.ts | TypeSafeClient configuration falls back to defaults when neither config nor env is set
|
|
30
|
+
func TestClient_FallsBackToDefaultsWhenNeitherConfigNorEnvIsSet(t *testing.T) {
|
|
31
|
+
twin(t, "client.test.ts | TypeSafeClient configuration falls back to defaults when neither config nor env is set")
|
|
32
|
+
c, err := NewClient(Config{APIKey: "k", Getenv: noEnv})
|
|
33
|
+
noErr(t, err)
|
|
34
|
+
eq(t, c.BaseURL(), DefaultBaseURL)
|
|
35
|
+
eq(t, c.DefaultModel(), DefaultModel)
|
|
36
|
+
eq(t, c.LogLevel(), DefaultLogLevel)
|
|
37
|
+
eq(t, c.RetryPolicy(), DefaultRetryPolicy())
|
|
38
|
+
eq(t, c.Timeout(), DefaultTimeout)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func TestClient_ReadsEverySettingFromTheEnvironment(t *testing.T) {
|
|
42
|
+
twin(t, "client.test.ts | TypeSafeClient configuration reads every setting from the environment")
|
|
43
|
+
env := envOf(map[string]string{EnvAPIKey: "env-key", EnvBaseURL: "https://env.test", EnvDefaultModel: "env-model", EnvLogLevel: "debug"})
|
|
44
|
+
c, err := NewClient(Config{Getenv: env, Logger: &recordingLogger{}})
|
|
45
|
+
noErr(t, err)
|
|
46
|
+
eq(t, sentAPIKey(t, Config{Getenv: env, Logger: &recordingLogger{}}), "env-key")
|
|
47
|
+
eq(t, c.BaseURL(), "https://env.test")
|
|
48
|
+
eq(t, c.DefaultModel(), "env-model")
|
|
49
|
+
eq(t, c.LogLevel(), LogDebug)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestClient_PrefersConfigOverTheEnvironment(t *testing.T) {
|
|
53
|
+
twin(t, "client.test.ts | TypeSafeClient configuration prefers config over the environment")
|
|
54
|
+
env := envOf(map[string]string{EnvAPIKey: "env-key", EnvBaseURL: "https://env.test", EnvDefaultModel: "env-model", EnvLogLevel: "debug"})
|
|
55
|
+
c, err := NewClient(Config{APIKey: "code-key", BaseURL: "https://code.test", DefaultModel: "code-model", LogLevel: LogError, Getenv: env})
|
|
56
|
+
noErr(t, err)
|
|
57
|
+
eq(t, sentAPIKey(t, Config{APIKey: "code-key", Getenv: env}), "code-key")
|
|
58
|
+
eq(t, c.BaseURL(), "https://code.test")
|
|
59
|
+
eq(t, c.DefaultModel(), "code-model")
|
|
60
|
+
eq(t, c.LogLevel(), LogError)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestClient_KeepsTheAPIKeyOffTheInstanceSoLoggingTheClientCannotLeakIt(t *testing.T) {
|
|
64
|
+
twin(t, "client.test.ts | TypeSafeClient configuration keeps the API key off the instance so logging the client cannot leak it")
|
|
65
|
+
c, err := NewClient(Config{APIKey: "super-secret", Getenv: noEnv})
|
|
66
|
+
noErr(t, err)
|
|
67
|
+
for _, s := range []string{fmt.Sprintf("%v", c), fmt.Sprintf("%+v", c), fmt.Sprintf("%#v", c), fmt.Sprint(c), fmt.Sprintf("%s", c)} {
|
|
68
|
+
if strings.Contains(s, "super-secret") {
|
|
69
|
+
t.Fatalf("formatted client leaks the key: %s", s)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
raw, err := json.Marshal(c)
|
|
73
|
+
noErr(t, err)
|
|
74
|
+
if strings.Contains(string(raw), "super-secret") {
|
|
75
|
+
t.Fatalf("JSON leaks the key: %s", raw)
|
|
76
|
+
}
|
|
77
|
+
typ := reflect.TypeOf(c).Elem()
|
|
78
|
+
for i := 0; i < typ.NumField(); i++ {
|
|
79
|
+
if f := typ.Field(i); f.IsExported() && strings.Contains(strings.ToLower(f.Name), "key") {
|
|
80
|
+
t.Fatalf("exported key field %s", f.Name)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestClient_TreatsEmptyAndWhitespaceOnlyEnvValuesAsUnset(t *testing.T) {
|
|
86
|
+
twin(t, "client.test.ts | TypeSafeClient configuration treats empty and whitespace-only env values as unset")
|
|
87
|
+
env := envOf(map[string]string{EnvAPIKey: "k", EnvBaseURL: " ", EnvDefaultModel: "", EnvLogLevel: ""})
|
|
88
|
+
c, err := NewClient(Config{Getenv: env})
|
|
89
|
+
noErr(t, err)
|
|
90
|
+
eq(t, c.BaseURL(), DefaultBaseURL)
|
|
91
|
+
eq(t, c.DefaultModel(), DefaultModel)
|
|
92
|
+
eq(t, c.LogLevel(), DefaultLogLevel)
|
|
93
|
+
// A whitespace-only key is also unset.
|
|
94
|
+
_, err = NewClient(Config{Getenv: envOf(map[string]string{EnvAPIKey: " \t "})})
|
|
95
|
+
if err == nil {
|
|
96
|
+
t.Fatal("a whitespace-only key must not be accepted")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestClient_ThrowsATypeSafeErrorNamingTheEnvVarWhenNoAPIKeyIsAvailable(t *testing.T) {
|
|
101
|
+
twin(t, "client.test.ts | TypeSafeClient configuration throws a TypeSafeError naming the env var when no API key is available")
|
|
102
|
+
_, err := NewClient(Config{Getenv: noEnv})
|
|
103
|
+
if err == nil {
|
|
104
|
+
t.Fatal("want an error")
|
|
105
|
+
}
|
|
106
|
+
mustAs[*TypeSafeError](t, err)
|
|
107
|
+
contains(t, err.Error(), EnvAPIKey)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestClient_StripsTrailingSlashesFromBaseURLFromEitherSource(t *testing.T) {
|
|
111
|
+
twin(t, "client.test.ts | TypeSafeClient configuration strips trailing slashes from baseURL from either source")
|
|
112
|
+
c, err := NewClient(Config{APIKey: "k", Getenv: envOf(map[string]string{EnvBaseURL: "https://example.test///"})})
|
|
113
|
+
noErr(t, err)
|
|
114
|
+
eq(t, c.BaseURL(), "https://example.test")
|
|
115
|
+
c, err = NewClient(Config{APIKey: "k", BaseURL: "https://x.test/", Getenv: noEnv})
|
|
116
|
+
noErr(t, err)
|
|
117
|
+
eq(t, c.BaseURL(), "https://x.test")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func TestClient_AcceptsEveryLogLevel(t *testing.T) {
|
|
121
|
+
twin(t,
|
|
122
|
+
"client.test.ts | TypeSafeClient configuration accepts log level debug",
|
|
123
|
+
"client.test.ts | TypeSafeClient configuration accepts log level info",
|
|
124
|
+
"client.test.ts | TypeSafeClient configuration accepts log level warn",
|
|
125
|
+
"client.test.ts | TypeSafeClient configuration accepts log level error",
|
|
126
|
+
"client.test.ts | TypeSafeClient configuration accepts log level off")
|
|
127
|
+
for _, level := range LogLevels {
|
|
128
|
+
c, err := NewClient(Config{APIKey: "k", LogLevel: level, Getenv: noEnv})
|
|
129
|
+
noErr(t, err)
|
|
130
|
+
eq(t, c.LogLevel(), level)
|
|
131
|
+
c, err = NewClient(Config{APIKey: "k", Getenv: envOf(map[string]string{EnvLogLevel: string(level)})})
|
|
132
|
+
noErr(t, err)
|
|
133
|
+
eq(t, c.LogLevel(), level)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func TestClient_RejectsAnInvalidLogLevelAndNamesWhereItCameFrom(t *testing.T) {
|
|
138
|
+
// Adapted: the option is named `LogLevel` in Go, so the message names "the `LogLevel` option".
|
|
139
|
+
twin(t, "client.test.ts | TypeSafeClient configuration rejects an invalid log level and names where it came from")
|
|
140
|
+
_, err := NewClient(Config{APIKey: "k", Getenv: envOf(map[string]string{EnvLogLevel: "loud"})})
|
|
141
|
+
mustAs[*TypeSafeError](t, err)
|
|
142
|
+
contains(t, err.Error(), `"loud" from `+EnvLogLevel)
|
|
143
|
+
contains(t, err.Error(), "debug, info, warn, error, off")
|
|
144
|
+
_, err = NewClient(Config{APIKey: "k", LogLevel: "loud", Getenv: noEnv})
|
|
145
|
+
mustAs[*TypeSafeError](t, err)
|
|
146
|
+
contains(t, err.Error(), "the `LogLevel` option")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestRequests_SendsAuthAndIdentifyingHeaders(t *testing.T) {
|
|
150
|
+
twin(t, "client.test.ts | requests sends auth and identifying headers")
|
|
151
|
+
m := always(func() *http.Response { return jsonResp(200, map[string]any{"models": []any{}}) })
|
|
152
|
+
c, err := NewClient(Config{APIKey: "secret", BaseURL: "https://x.test", HTTPClient: m, Getenv: noEnv})
|
|
153
|
+
noErr(t, err)
|
|
154
|
+
_, err = c.Models().List(ctxBG(), nil)
|
|
155
|
+
noErr(t, err)
|
|
156
|
+
eq(t, m.count(), 1)
|
|
157
|
+
r := m.req(0)
|
|
158
|
+
eq(t, r.URL, "https://x.test/v1/models")
|
|
159
|
+
eq(t, r.Method, "GET")
|
|
160
|
+
eq(t, r.Header.Get("Authorization"), "Bearer secret")
|
|
161
|
+
eq(t, r.Header.Get("User-Agent"), "typesafe-sdk-go/"+Version)
|
|
162
|
+
eq(t, r.Header.Get("X-TypeSafe-SDK"), "typesafe-sdk-go/"+Version)
|
|
163
|
+
runtimeHeader := r.Header.Get("X-TypeSafe-Runtime")
|
|
164
|
+
eq(t, runtimeHeader, "go/"+strings.TrimPrefix(runtime.Version(), "go")+" ("+runtime.GOOS+"; "+runtime.GOARCH+")")
|
|
165
|
+
if !regexp.MustCompile(`^go/\d+\.\d+(\.\d+)? \(\w+; \w+\)$`).MatchString(runtimeHeader) {
|
|
166
|
+
t.Fatalf("runtime header %q", runtimeHeader)
|
|
167
|
+
}
|
|
168
|
+
eq(t, r.Header.Get("Accept"), "application/json")
|
|
169
|
+
eq(t, r.Header.Get("Content-Type"), "")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestRequests_MergesDefaultHeadersAndPerCallHeadersPerCallWinningNeverClobberingAuth(t *testing.T) {
|
|
173
|
+
twin(t, "client.test.ts | requests merges defaultHeaders and per-call headers, per-call winning, never clobbering auth")
|
|
174
|
+
m := always(func() *http.Response { return jsonResp(200, map[string]any{"models": []any{}}) })
|
|
175
|
+
c := newClient(t, m, func(cfg *Config) {
|
|
176
|
+
cfg.APIKey = "secret"
|
|
177
|
+
cfg.DefaultHeaders = map[string]string{"X-Trace": "client", "X-Only-Default": "yes", "Authorization": "nope"}
|
|
178
|
+
})
|
|
179
|
+
_, err := c.Models().List(ctxBG(), &RequestOptions{Headers: map[string]string{"X-Trace": "call", "X-Only-Call": "yes"}})
|
|
180
|
+
noErr(t, err)
|
|
181
|
+
h := m.req(0).Header
|
|
182
|
+
eq(t, h.Get("X-Trace"), "call")
|
|
183
|
+
eq(t, h.Get("X-Only-Default"), "yes")
|
|
184
|
+
eq(t, h.Get("X-Only-Call"), "yes")
|
|
185
|
+
eq(t, h.Get("Authorization"), "Bearer secret")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestRequests_ModelsListUnwrapsTheDocumentedResponse(t *testing.T) {
|
|
189
|
+
twin(t,
|
|
190
|
+
`client.test.ts | requests models.list() unwraps the documented response: {"cards":[]}`,
|
|
191
|
+
`client.test.ts | requests models.list() unwraps the documented response: {"cards":[{"name":"m","description":"d","release_date":"2026"}]}`)
|
|
192
|
+
for _, cards := range [][]ModelCard{{}, modelCards} {
|
|
193
|
+
wire := map[string]any{"models": cards}
|
|
194
|
+
c := newClient(t, always(func() *http.Response { return jsonResp(200, wire) }))
|
|
195
|
+
got, err := c.Models().List(ctxBG(), nil)
|
|
196
|
+
noErr(t, err)
|
|
197
|
+
eq(t, got, cards)
|
|
198
|
+
res, err := c.Models().ListWithResponse(ctxBG(), nil)
|
|
199
|
+
noErr(t, err)
|
|
200
|
+
eq(t, res.Data, cards)
|
|
201
|
+
eq(t, res.Status, 200)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func TestRequests_ModelsListFailsClearlyOnAnUnrecognizedShape(t *testing.T) {
|
|
206
|
+
twin(t,
|
|
207
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":null}`,
|
|
208
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":[]}`,
|
|
209
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":{"models":{"models":[]}}}`,
|
|
210
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":{"models":null}}`,
|
|
211
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":{"models":"bad"}}`,
|
|
212
|
+
`client.test.ts | requests models.list() fails clearly on an unrecognized shape: {"wire":{"ok":true}}`)
|
|
213
|
+
for _, wire := range []string{`null`, `[]`, `{"models":{"models":[]}}`, `{"models":null}`, `{"models":"bad"}`, `{"ok":true}`} {
|
|
214
|
+
c := newClient(t, always(func() *http.Response { return jsonResp(200, decode(t, wire)) }))
|
|
215
|
+
_, err := c.Models().List(ctxBG(), nil)
|
|
216
|
+
if err == nil {
|
|
217
|
+
t.Fatalf("%s: want an error", wire)
|
|
218
|
+
}
|
|
219
|
+
mustAs[*TypeSafeError](t, err)
|
|
220
|
+
contains(t, err.Error(), "Unexpected response shape from GET /v1/models")
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func TestRequests_PreservesEmployeeOnlyModelFieldsThroughRawAccess(t *testing.T) {
|
|
225
|
+
// Adapted: Go has no unconsumed Response body; the raw response carries the buffered body.
|
|
226
|
+
twin(t, "client.test.ts | requests preserves employee-only model fields through raw access")
|
|
227
|
+
wire := map[string]any{"models": []any{map[string]any{"name": "m", "description": "d", "release_date": "2026", "tags": []any{"internal"}}}}
|
|
228
|
+
c := newClient(t, always(func() *http.Response { return jsonResp(200, wire) }))
|
|
229
|
+
raw, err := c.Models().ListRaw(ctxBG(), nil)
|
|
230
|
+
noErr(t, err)
|
|
231
|
+
jsonEq(t, json.RawMessage(raw.Body), string(mustJSON(t, wire)))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func mustJSON(t testing.TB, v any) []byte {
|
|
235
|
+
t.Helper()
|
|
236
|
+
b, err := json.Marshal(v)
|
|
237
|
+
noErr(t, err)
|
|
238
|
+
return b
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
func TestRequests_PostsTheSystemOnePayloadWithTheDefaultModel(t *testing.T) {
|
|
242
|
+
twin(t, "client.test.ts | requests posts the systemOne payload with the default model")
|
|
243
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
244
|
+
c, err := NewClient(Config{APIKey: "k", BaseURL: "https://x.test", HTTPClient: m, Getenv: noEnv})
|
|
245
|
+
noErr(t, err)
|
|
246
|
+
res, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Value(map[string]any{"a": 1}), Questions: Questions{Ask("q1", Noul("x"))}}, nil)
|
|
247
|
+
noErr(t, err)
|
|
248
|
+
r := m.req(0)
|
|
249
|
+
eq(t, r.URL, "https://x.test/v1/systemone")
|
|
250
|
+
eq(t, r.Method, "POST")
|
|
251
|
+
eq(t, r.Body, decode(t, `{"state":{"a":1},"model":"jev-latest","questions":{"q1":{"type":"noul","instructions":"x"}}}`))
|
|
252
|
+
eq(t, r.Header.Get("Content-Type"), "application/json")
|
|
253
|
+
a, err := res.Noul("q1")
|
|
254
|
+
noErr(t, err)
|
|
255
|
+
eq(t, a.Noul, 0.5)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func TestRequests_SendsTheRequestObjectAsThePayloadFillingInTheDefaultModel(t *testing.T) {
|
|
259
|
+
twin(t, "client.test.ts | requests sends the request object as the payload, filling in the default model")
|
|
260
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
261
|
+
c := newClient(t, m)
|
|
262
|
+
req := SystemOneRequest{State: Value(map[string]any{"a": 1}), Questions: Questions{Ask("q1", Noul("x"))}}
|
|
263
|
+
_, err := c.SystemOne(ctxBG(), req, nil)
|
|
264
|
+
noErr(t, err)
|
|
265
|
+
eq(t, m.req(0).Body, decode(t, `{"state":{"a":1},"model":"jev-latest","questions":{"q1":{"type":"noul","instructions":"x"}}}`))
|
|
266
|
+
req.Model = "explicit"
|
|
267
|
+
_, err = c.SystemOne(ctxBG(), req, nil)
|
|
268
|
+
noErr(t, err)
|
|
269
|
+
eq(t, m.req(1).Body, decode(t, `{"state":{"a":1},"model":"explicit","questions":{"q1":{"type":"noul","instructions":"x"}}}`))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestRequests_HonorsPerCallModelAndClientDefaultModel(t *testing.T) {
|
|
273
|
+
twin(t, "client.test.ts | requests honors per-call model and client defaultModel")
|
|
274
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
275
|
+
c := newClient(t, m, func(cfg *Config) { cfg.DefaultModel = "client-default" })
|
|
276
|
+
qs := Questions{Ask("q", Choice("c", Opt("a", nil)))}
|
|
277
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: qs}, nil)
|
|
278
|
+
noErr(t, err)
|
|
279
|
+
_, err = c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: qs, Model: "per-call"}, nil)
|
|
280
|
+
noErr(t, err)
|
|
281
|
+
eq(t, m.req(0).Body.(map[string]any)["model"], any("client-default"))
|
|
282
|
+
eq(t, m.req(1).Body.(map[string]any)["model"], any("per-call"))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
func TestRequests_AbortingTheCallersSignalAbortsTheSignalHandedToFetchAndWrapsTheError(t *testing.T) {
|
|
286
|
+
// Adapted: the AbortSignal is the call's context.
|
|
287
|
+
twin(t, "client.test.ts | requests aborting the caller's signal aborts the signal handed to fetch, and wraps the error")
|
|
288
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
289
|
+
m := newMock(func(r *recordedRequest) (*http.Response, error) {
|
|
290
|
+
cancel()
|
|
291
|
+
return nil, context.Canceled
|
|
292
|
+
})
|
|
293
|
+
c := newClient(t, m)
|
|
294
|
+
_, err := c.Models().List(ctx, nil)
|
|
295
|
+
mustAs[*APIUserAbortError](t, err)
|
|
296
|
+
if !errors.Is(err, context.Canceled) {
|
|
297
|
+
t.Fatalf("the abort must carry the context error: %v", err)
|
|
298
|
+
}
|
|
299
|
+
if m.req(0).Ctx.Err() == nil {
|
|
300
|
+
t.Fatal("the context handed to the transport must be cancelled")
|
|
301
|
+
}
|
|
302
|
+
contains(t, err.Error(), "Request was aborted.")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
func TestRequests_WrapsNetworkFailuresInAPIConnectionErrorWithTheCause(t *testing.T) {
|
|
306
|
+
twin(t, "client.test.ts | requests wraps network failures in APIConnectionError with the cause")
|
|
307
|
+
boom := errors.New("fetch failed")
|
|
308
|
+
c := newClient(t, newMock(func(*recordedRequest) (*http.Response, error) { return nil, boom }), func(cfg *Config) { cfg.Retry = RetryOverrides{MaxRetries: Ptr(0)} })
|
|
309
|
+
_, err := c.Models().List(ctxBG(), nil)
|
|
310
|
+
conn := mustAs[*APIConnectionError](t, err)
|
|
311
|
+
if !errors.Is(conn, boom) || !errors.Is(err, boom) {
|
|
312
|
+
t.Fatal("the cause must be reachable")
|
|
313
|
+
}
|
|
314
|
+
contains(t, err.Error(), "fetch failed")
|
|
315
|
+
contains(t, err.Error(), "Connection error:")
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func TestQuestionBuilders_ChoiceAcceptsACriteriaObjectAsIs(t *testing.T) {
|
|
319
|
+
twin(t, "client.test.ts | question builders choice accepts a criteria object as-is")
|
|
320
|
+
jsonEq(t, Choice("q", Opt("a", "desc"), Opt("b", nil)), `{"type":"choice","instructions":"q","criteria":{"a":"desc","b":null}}`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func TestQuestionBuilders_ChoiceRejectsTheRemovedLabelListShorthandInJavaScriptToo(t *testing.T) {
|
|
324
|
+
skipTwin(t, "JavaScript-caller check: Go's Choice takes []Option, so a bare label list cannot be expressed; the JSON-side rejection is TestParseQuestions_RejectsAChoiceLabelList",
|
|
325
|
+
"client.test.ts | question builders choice rejects the removed label-list shorthand in JavaScript too")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func TestQuestionBuilders_NoulAllowsDescribingOneSideBothOrNeither(t *testing.T) {
|
|
329
|
+
twin(t, "client.test.ts | question builders noul allows describing one side, both, or neither")
|
|
330
|
+
jsonEq(t, Noul("q"), `{"type":"noul","instructions":"q"}`)
|
|
331
|
+
jsonEq(t, Noul("q").Yes("yes means this"), `{"type":"noul","instructions":"q","criteria":{"true":"yes means this"}}`)
|
|
332
|
+
jsonEq(t, Noul("q").No("no means this").Criteria, `{"false":"no means this"}`)
|
|
333
|
+
jsonEq(t, Noul("q").Yes("a").No("b").Criteria, `{"true":"a","false":"b"}`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func TestQuestionBuilders_DescriptionsCanBeJSONObjectsNotOnlyStrings(t *testing.T) {
|
|
337
|
+
twin(t, "client.test.ts | question builders descriptions can be JSON objects, not only strings")
|
|
338
|
+
rich := map[string]any{"summary": "warm", "examples": []any{"hi!", "welcome"}}
|
|
339
|
+
q := Choice("q", Opt("friendly", rich), Opt("hostile", nil))
|
|
340
|
+
jsonEq(t, q.Criteria[0].Description, `{"summary":"warm","examples":["hi!","welcome"]}`)
|
|
341
|
+
jsonEq(t, Score("q", rich, "meh").Criteria[0], `{"summary":"warm","examples":["hi!","welcome"]}`)
|
|
342
|
+
jsonEq(t, Noul("q").Yes(rich).Criteria.True, `{"summary":"warm","examples":["hi!","welcome"]}`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func TestQuestionBuilders_ChoiceAnswersCarryTheWinningLabel(t *testing.T) {
|
|
346
|
+
twin(t, "client.test.ts | question builders choice answers carry the winning label")
|
|
347
|
+
wire := map[string]any{"model": "m", "answers": map[string]any{"q": map[string]any{"type": "choice", "choice": "b", "confidence": 0.9, "probabilities": map[string]any{"a": 0.1, "b": 0.9}}}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}}
|
|
348
|
+
c := newClient(t, always(func() *http.Response { return jsonResp(200, wire) }))
|
|
349
|
+
r, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", Choice("q", Opt("a", nil), Opt("b", nil)))}}, nil)
|
|
350
|
+
noErr(t, err)
|
|
351
|
+
a, err := r.Choice("q")
|
|
352
|
+
noErr(t, err)
|
|
353
|
+
eq(t, a.Choice, "b")
|
|
354
|
+
eq(t, a.Probabilities["b"], 0.9)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func TestQuestionBuilders_ScoreKeepsTheListItWasGivenAndRejectsMaps(t *testing.T) {
|
|
358
|
+
twin(t, "client.test.ts | question builders score keeps the list it was given and rejects maps")
|
|
359
|
+
eq(t, Score("q", "bad", "good").Criteria, []Entry{Text("bad"), Text("good")})
|
|
360
|
+
// The "rejects maps" half is a JavaScript-caller check: Go's Score takes a list. The JSON-side
|
|
361
|
+
// rejection is TestParseQuestions_RejectsAScoreMap.
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
func TestWireFormat_PreservesNullStateInstructionsAndCriteriaValues(t *testing.T) {
|
|
365
|
+
twin(t, "client.test.ts | wire format preserves null state, instructions, and criteria values")
|
|
366
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
367
|
+
c := newClient(t, m)
|
|
368
|
+
qs := Questions{
|
|
369
|
+
Ask("noul", Noul(nil).Yes(nil).No(nil)),
|
|
370
|
+
Ask("noCriteria", Noul(nil).NullCriteria()),
|
|
371
|
+
Ask("choice", Choice(nil, Opt("yes", nil), Opt("no", nil))),
|
|
372
|
+
Ask("score", Score(nil, nil, "high")),
|
|
373
|
+
}
|
|
374
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Null, Questions: qs}, nil)
|
|
375
|
+
noErr(t, err)
|
|
376
|
+
eq(t, m.req(0).Body, decode(t, `{"model":"jev-latest","state":null,"questions":{
|
|
377
|
+
"noul":{"type":"noul","instructions":null,"criteria":{"true":null,"false":null}},
|
|
378
|
+
"noCriteria":{"type":"noul","instructions":null,"criteria":null},
|
|
379
|
+
"choice":{"type":"choice","instructions":null,"criteria":{"yes":null,"no":null}},
|
|
380
|
+
"score":{"type":"score","instructions":null,"criteria":[null,"high"]}}}`))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
func TestWireFormat_AllowsOmittedInstructionsAndPreservesJSONArrays(t *testing.T) {
|
|
384
|
+
twin(t, "client.test.ts | wire format allows omitted instructions and preserves JSON arrays")
|
|
385
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
386
|
+
c := newClient(t, m)
|
|
387
|
+
qs := Questions{
|
|
388
|
+
Ask("noul", NoulQuestion{Criteria: &NoulCriteria{Null: true}}),
|
|
389
|
+
Ask("choice", ChoiceQuestion{Criteria: []Option{{Label: "yes", Description: Value([]any{nil, map[string]any{"example": true}})}}}),
|
|
390
|
+
Ask("score", ScoreQuestion{Criteria: []Entry{Value([]any{nil, "low"}), Null}}),
|
|
391
|
+
Ask("arrayInstructions", Noul([]any{nil, map[string]any{"examples": []any{1, false}}}).Yes([]any{"yes", nil})),
|
|
392
|
+
Ask("defaultInstructions", Noul(nil)),
|
|
393
|
+
}
|
|
394
|
+
state := Value([]any{nil, map[string]any{"messages": []any{"hello"}}})
|
|
395
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{State: state, Questions: qs}, nil)
|
|
396
|
+
noErr(t, err)
|
|
397
|
+
eq(t, m.req(0).Body, decode(t, `{"model":"jev-latest","state":[null,{"messages":["hello"]}],"questions":{
|
|
398
|
+
"noul":{"type":"noul","criteria":null},
|
|
399
|
+
"choice":{"type":"choice","criteria":{"yes":[null,{"example":true}]}},
|
|
400
|
+
"score":{"type":"score","criteria":[[null,"low"],null]},
|
|
401
|
+
"arrayInstructions":{"type":"noul","instructions":[null,{"examples":[1,false]}],"criteria":{"true":["yes",null]}},
|
|
402
|
+
"defaultInstructions":{"type":"noul","instructions":null}}}`))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
func TestWireFormat_SendsChoiceCriteriaWithoutRewriting(t *testing.T) {
|
|
406
|
+
twin(t, "client.test.ts | wire format sends choice criteria without rewriting")
|
|
407
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
408
|
+
_, err := newClient(t, m).SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", Choice("which?", Opt("a", nil), Opt("b", nil)))}}, nil)
|
|
409
|
+
noErr(t, err)
|
|
410
|
+
eq(t, m.req(0).Body.(map[string]any)["questions"].(map[string]any)["q"].(map[string]any)["criteria"], decode(t, `{"a":null,"b":null}`))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
func TestWireFormat_SendsAScoreListUntouched(t *testing.T) {
|
|
414
|
+
twin(t, "client.test.ts | wire format sends a score list untouched")
|
|
415
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
416
|
+
_, err := newClient(t, m).SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", Score("q", "bad", "ok", "great"))}}, nil)
|
|
417
|
+
noErr(t, err)
|
|
418
|
+
eq(t, m.req(0).Body.(map[string]any)["questions"].(map[string]any)["q"].(map[string]any)["criteria"], decode(t, `["bad","ok","great"]`))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func TestWireFormat_RejectsNonListScoreCriteriaFewerThanTwoCriteriaAndEmptyQuestionSetsBeforeSending(t *testing.T) {
|
|
422
|
+
// Adapted: the non-list (map) criteria case is a JavaScript-caller input; Go's ScoreQuestion.Criteria is a slice.
|
|
423
|
+
twin(t, "client.test.ts | wire format rejects non-list score criteria, fewer than two criteria, and empty question sets before sending")
|
|
424
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
425
|
+
c := newClient(t, m)
|
|
426
|
+
bad := func(criteria ...Entry) error {
|
|
427
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", ScoreQuestion{Criteria: criteria})}}, nil)
|
|
428
|
+
return err
|
|
429
|
+
}
|
|
430
|
+
err := bad()
|
|
431
|
+
mustAs[*TypeSafeError](t, err)
|
|
432
|
+
contains(t, err.Error(), `Score question "q" has 0 criteria; at least two scores are required.`)
|
|
433
|
+
err = bad(Text("only"))
|
|
434
|
+
mustAs[*TypeSafeError](t, err)
|
|
435
|
+
contains(t, err.Error(), "at least two scores")
|
|
436
|
+
_, err = c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{}}, nil)
|
|
437
|
+
mustAs[*TypeSafeError](t, err)
|
|
438
|
+
contains(t, err.Error(), "At least one question is required")
|
|
439
|
+
eq(t, m.count(), 0)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
func TestPrimitiveContract_UsesATenSecondDefaultAndKeepsRawMetadataAccess(t *testing.T) {
|
|
443
|
+
twin(t, "client.test.ts | 1.0 primitive contract uses a ten-second default and keeps raw metadata access")
|
|
444
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse, "x-typesafe-request-id", "req_1") })
|
|
445
|
+
c := newClient(t, m)
|
|
446
|
+
eq(t, c.Timeout(), 10*time.Second)
|
|
447
|
+
res, err := c.SystemOneWithResponse(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q1", Noul("?"))}}, nil)
|
|
448
|
+
noErr(t, err)
|
|
449
|
+
jsonEq(t, res.Data, string(mustJSON(t, systemOneResponse)))
|
|
450
|
+
eq(t, res.RequestID, "req_1")
|
|
451
|
+
eq(t, m.count(), 1)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
func TestPrimitiveContract_ForwardsExtraFieldsAndNull(t *testing.T) {
|
|
455
|
+
twin(t, "client.test.ts | 1.0 primitive contract forwards extra fields and null")
|
|
456
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
457
|
+
c := newClient(t, m)
|
|
458
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{
|
|
459
|
+
State: Text("s"), Questions: Questions{Ask("q", Score("?", "low", "high"))},
|
|
460
|
+
Extra: map[string]any{"future_option": nil, "nested": map[string]any{"enabled": true}},
|
|
461
|
+
}, nil)
|
|
462
|
+
noErr(t, err)
|
|
463
|
+
eq(t, m.req(0).Body, decode(t, `{"state":"s","questions":{"q":{"type":"score","instructions":"?","criteria":["low","high"]}},"future_option":null,"nested":{"enabled":true},"model":"jev-latest"}`))
|
|
464
|
+
_, err = c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", Noul("?"))}}, nil)
|
|
465
|
+
noErr(t, err)
|
|
466
|
+
if _, has := m.req(1).Body.(map[string]any)["future_option"]; has {
|
|
467
|
+
t.Fatal("extra fields must not leak between requests")
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Go-specific cases beyond the upstream suite.
|
|
472
|
+
|
|
473
|
+
func TestRequests_ExtraFieldsMayNotShadowTheRequestFields(t *testing.T) {
|
|
474
|
+
c := newClient(t, always(func() *http.Response { return jsonResp(200, systemOneResponse) }))
|
|
475
|
+
for _, name := range []string{"state", "questions", "model"} {
|
|
476
|
+
_, err := c.SystemOne(ctxBG(), SystemOneRequest{State: Text("s"), Questions: Questions{Ask("q", Noul("?"))}, Extra: map[string]any{name: 1}}, nil)
|
|
477
|
+
mustAs[*TypeSafeError](t, err)
|
|
478
|
+
contains(t, err.Error(), name)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func TestRequests_ARequestWithoutStateOmitsIt(t *testing.T) {
|
|
483
|
+
m := always(func() *http.Response { return jsonResp(200, systemOneResponse) })
|
|
484
|
+
_, err := newClient(t, m).SystemOne(ctxBG(), SystemOneRequest{Questions: Questions{Ask("q", Noul("?"))}}, nil)
|
|
485
|
+
noErr(t, err)
|
|
486
|
+
if _, has := m.req(0).Body.(map[string]any)["state"]; has {
|
|
487
|
+
t.Fatal("an omitted state must not be sent")
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
func TestRequests_NegativeTimeoutIsRejected(t *testing.T) {
|
|
492
|
+
_, err := NewClient(Config{APIKey: "k", Timeout: -time.Second, Getenv: noEnv})
|
|
493
|
+
mustAs[*TypeSafeError](t, err)
|
|
494
|
+
contains(t, err.Error(), "Timeout")
|
|
495
|
+
}
|