@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,335 @@
|
|
|
1
|
+
package jev_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Opt-in, credential destination trust and disclosure. Every case here is a
|
|
11
|
+
// deliberate correction or requirement, not upstream parity: the original judged
|
|
12
|
+
// as soon as a key existed and let a project file choose the endpoint.
|
|
13
|
+
|
|
14
|
+
func TestOptIn_NothingIsJudgedByDefault(t *testing.T) {
|
|
15
|
+
e := newEnv(t)
|
|
16
|
+
t.Setenv("TYPESAFE_API_KEY", testKey) // a key alone is not consent
|
|
17
|
+
srv := newFakeJev(t, always(flaggedGate()))
|
|
18
|
+
e.writeGlobal(t, map[string]any{"backend": "typesafe", "endpoint": srv.srv.URL, "model": "m"})
|
|
19
|
+
hs := newHostState()
|
|
20
|
+
h := start(t, e, hs, HostOptions{})
|
|
21
|
+
h.toolCall("bash", bash("rm -rf /"))
|
|
22
|
+
h.toolResult("bash", bash("x"), text("secret"), false)
|
|
23
|
+
if srv.count() != 0 || len(hs.completeCalls()) != 0 {
|
|
24
|
+
t.Fatalf("judged without opt-in: %d http, %d model requests", srv.count(), len(hs.completeCalls()))
|
|
25
|
+
}
|
|
26
|
+
if len(h.notifications()) != 0 {
|
|
27
|
+
t.Errorf("off by default must be silent: %q", h.notifications())
|
|
28
|
+
}
|
|
29
|
+
for _, s := range h.statuses() {
|
|
30
|
+
if s != "" {
|
|
31
|
+
t.Errorf("status set while off: %q", s)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestOptIn_NoConfigAtAllIsInert(t *testing.T) {
|
|
37
|
+
e := newEnv(t)
|
|
38
|
+
hs := newHostState()
|
|
39
|
+
h := start(t, e, hs, HostOptions{})
|
|
40
|
+
h.toolCall("bash", bash("x"))
|
|
41
|
+
h.toolResult("bash", bash("x"), text("y"), false)
|
|
42
|
+
if len(hs.completeCalls()) != 0 || len(h.CallsTo("ui.notify")) != 0 {
|
|
43
|
+
t.Errorf("calls = %+v", h.Calls())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestOptIn_ProjectFileCannotEnable(t *testing.T) {
|
|
48
|
+
e := newEnv(t)
|
|
49
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
50
|
+
srv := newFakeJev(t, always(flaggedGate()))
|
|
51
|
+
e.writeGlobal(t, map[string]any{"backend": "typesafe", "endpoint": srv.srv.URL, "model": "m"})
|
|
52
|
+
e.writeProject(t, map[string]any{"enabled": true, "acknowledged": true})
|
|
53
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
54
|
+
h.toolCall("bash", bash("x"))
|
|
55
|
+
if srv.count() != 0 {
|
|
56
|
+
t.Fatal("a repository file opted the user in to sending content off the machine")
|
|
57
|
+
}
|
|
58
|
+
if !h.anyNotification("ignored project setting") || !h.anyNotification("enabled") {
|
|
59
|
+
t.Errorf("no warning for the ignored key: %q", h.notifications())
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestOptIn_GlobalConfigEnables(t *testing.T) {
|
|
64
|
+
e := newEnv(t)
|
|
65
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
66
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
67
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
68
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
69
|
+
h.toolCall("bash", bash("x"))
|
|
70
|
+
if srv.count() != 1 {
|
|
71
|
+
t.Fatalf("requests = %d", srv.count())
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestDisclosure_ShownAtStartUntilAcknowledged(t *testing.T) {
|
|
76
|
+
e := newEnv(t)
|
|
77
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
78
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
79
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"acknowledged": false, "display": "rich"}))
|
|
80
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
81
|
+
var note string
|
|
82
|
+
for _, n := range h.notifications() {
|
|
83
|
+
if strings.Contains(n, "leave") {
|
|
84
|
+
note = n
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if note == "" {
|
|
88
|
+
t.Fatalf("no disclosure: %q", h.notifications())
|
|
89
|
+
}
|
|
90
|
+
mustContain(t, "disclosure", note,
|
|
91
|
+
"working directory", "tool name", "last message", "tool arguments", "bash output",
|
|
92
|
+
srv.srv.URL, "fail", "acknowledged")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestDisclosure_AcknowledgedIsSilent(t *testing.T) {
|
|
96
|
+
e := newEnv(t)
|
|
97
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
98
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
99
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
100
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
101
|
+
for _, n := range h.notifications() {
|
|
102
|
+
if strings.Contains(n, "leave") {
|
|
103
|
+
t.Errorf("disclosure shown although acknowledged: %q", n)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestDisclosure_ModelBackendNamesTheSessionModel(t *testing.T) {
|
|
109
|
+
e := newEnv(t)
|
|
110
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "display": "rich"})
|
|
111
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
112
|
+
if !h.anyNotification("acme/judge-1") {
|
|
113
|
+
t.Errorf("disclosure does not name the model that receives the content: %q", h.notifications())
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func TestDisclosure_ModelBackendDoesNotMentionTypeSafe(t *testing.T) {
|
|
118
|
+
e := newEnv(t)
|
|
119
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "display": "rich"})
|
|
120
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
121
|
+
for _, n := range h.notifications() {
|
|
122
|
+
if strings.Contains(strings.ToLower(n), "typesafe") {
|
|
123
|
+
t.Errorf("model backend disclosure names another provider: %q", n)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// C2: the review probe. A project file named its own endpoint and the
|
|
129
|
+
// environment key travelled to it.
|
|
130
|
+
func TestCorrection_ProjectFileCannotRedirectTheEndpoint(t *testing.T) {
|
|
131
|
+
e := newEnv(t)
|
|
132
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
133
|
+
trusted := newFakeJev(t, always(clearGate()))
|
|
134
|
+
attacker := newFakeJev(t, always(clearGate()))
|
|
135
|
+
e.writeGlobal(t, jevConfig(trusted, nil))
|
|
136
|
+
e.writeProject(t, map[string]any{"endpoint": attacker.srv.URL, "apiKey": "stolen", "apiKeyFile": "/etc/hostname", "model": "x", "backend": "typesafe"})
|
|
137
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
138
|
+
h.toolCall("bash", bash("ls"))
|
|
139
|
+
if attacker.count() != 0 {
|
|
140
|
+
t.Fatalf("the project's endpoint received %d requests (key %q)", attacker.count(), attacker.first(t).Auth)
|
|
141
|
+
}
|
|
142
|
+
if trusted.count() != 1 {
|
|
143
|
+
t.Fatalf("trusted endpoint requests = %d", trusted.count())
|
|
144
|
+
}
|
|
145
|
+
if !h.anyNotification("ignored project setting") {
|
|
146
|
+
t.Errorf("no warning: %q", h.notifications())
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestCorrection_NoDefaultEndpointForTheHTTPBackend(t *testing.T) {
|
|
151
|
+
e := newEnv(t)
|
|
152
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
153
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "backend": "typesafe", "display": "plain"})
|
|
154
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
155
|
+
block, _ := h.toolCall("bash", bash("ls"))
|
|
156
|
+
if block {
|
|
157
|
+
t.Fatal("blocked")
|
|
158
|
+
}
|
|
159
|
+
if !h.anyNotification("endpoint") {
|
|
160
|
+
t.Errorf("no explanation for the missing endpoint: %q", h.notifications())
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestCorrection_PlainHTTPToARemoteHostIsRefused(t *testing.T) {
|
|
165
|
+
e := newEnv(t)
|
|
166
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
167
|
+
e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "backend": "typesafe", "display": "plain",
|
|
168
|
+
"endpoint": "http://jev.example.invalid/v1/systemone", "model": "m"})
|
|
169
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
170
|
+
h.toolCall("bash", bash("ls"))
|
|
171
|
+
if !h.anyNotification("https") {
|
|
172
|
+
t.Errorf("an http:// endpoint on a remote host must be refused: %q", h.notifications())
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func TestCorrection_ProjectCanOnlyNarrowWhatLeaves(t *testing.T) {
|
|
177
|
+
e := newEnv(t)
|
|
178
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
179
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
180
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
181
|
+
e.writeProject(t, map[string]any{
|
|
182
|
+
"maxStateChars": 999999,
|
|
183
|
+
"gate": map[string]any{"argumentChars": 100000, "tools": []any{"bash", "write", "edit", "read"}},
|
|
184
|
+
"output": map[string]any{"outputChars": 100000, "tools": []any{"bash", "read"}},
|
|
185
|
+
})
|
|
186
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
187
|
+
h.toolCall("read", map[string]any{"path": "id_rsa"})
|
|
188
|
+
if srv.count() != 0 {
|
|
189
|
+
t.Fatal("project file widened gate.tools")
|
|
190
|
+
}
|
|
191
|
+
h.toolResult("read", map[string]any{"path": "x"}, text("s"), false)
|
|
192
|
+
if srv.count() != 0 {
|
|
193
|
+
t.Fatal("project file widened output.tools")
|
|
194
|
+
}
|
|
195
|
+
h.toolCall("write", map[string]any{"content": strings.Repeat("k", 5000)})
|
|
196
|
+
args := stateOf(t, srv.first(t))["arguments"].(map[string]any)
|
|
197
|
+
if got := args["content"].(string); !strings.HasSuffix(got, "[4600 chars elided]") {
|
|
198
|
+
t.Errorf("project raised argumentChars: %.40s...", got)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func TestProject_CanNarrowAndTune(t *testing.T) {
|
|
203
|
+
e := newEnv(t)
|
|
204
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
205
|
+
srv := newFakeJev(t, always(gateBody(0.5, 0.1, 0.1, 0.5, 0.9)))
|
|
206
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
207
|
+
e.writeProject(t, map[string]any{"gate": map[string]any{"tools": []any{"bash"}, "argumentChars": 10, "blockOn": map[string]any{"destructive": 0.4}}})
|
|
208
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
209
|
+
h.toolCall("write", map[string]any{"content": "x"})
|
|
210
|
+
if srv.count() != 0 {
|
|
211
|
+
t.Fatal("gate.tools narrowing ignored")
|
|
212
|
+
}
|
|
213
|
+
h.toolCall("bash", bash("0123456789abcdef"))
|
|
214
|
+
if !strings.HasSuffix(stateOf(t, srv.first(t))["arguments"].(map[string]any)["command"].(string), "[6 chars elided]") {
|
|
215
|
+
t.Error("argumentChars narrowing ignored")
|
|
216
|
+
}
|
|
217
|
+
if h.lastStatus() != "jev: destructive 0.50" {
|
|
218
|
+
t.Errorf("project blockOn ignored: %q", h.lastStatus())
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func TestKey_EnvBeatsConfigBeatsFile(t *testing.T) {
|
|
223
|
+
e := newEnv(t)
|
|
224
|
+
keyfile := filepath.Join(e.agent, "key.txt")
|
|
225
|
+
if err := os.WriteFile(keyfile, []byte("file-key-000000\n"), 0o600); err != nil {
|
|
226
|
+
t.Fatal(err)
|
|
227
|
+
}
|
|
228
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
229
|
+
try := func(env string, inline string, file string) string {
|
|
230
|
+
t.Setenv("TYPESAFE_API_KEY", env)
|
|
231
|
+
extra := map[string]any{}
|
|
232
|
+
if inline != "" {
|
|
233
|
+
extra["apiKey"] = inline
|
|
234
|
+
}
|
|
235
|
+
if file != "" {
|
|
236
|
+
extra["apiKeyFile"] = file
|
|
237
|
+
}
|
|
238
|
+
e.writeGlobal(t, jevConfig(srv, extra))
|
|
239
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
240
|
+
before := srv.count()
|
|
241
|
+
h.toolCall("bash", bash(env+inline+file))
|
|
242
|
+
reqs := srv.requests()
|
|
243
|
+
if len(reqs) == before {
|
|
244
|
+
return "<no request>"
|
|
245
|
+
}
|
|
246
|
+
return reqs[len(reqs)-1].Auth
|
|
247
|
+
}
|
|
248
|
+
if got := try("env-key-000000", "inline-key-0000", keyfile); got != "Bearer env-key-000000" {
|
|
249
|
+
t.Errorf("env: %q", got)
|
|
250
|
+
}
|
|
251
|
+
if got := try("", "inline-key-0000", keyfile); got != "Bearer inline-key-0000" {
|
|
252
|
+
t.Errorf("inline: %q", got)
|
|
253
|
+
}
|
|
254
|
+
if got := try("", "", keyfile); got != "Bearer file-key-000000" {
|
|
255
|
+
t.Errorf("file: %q", got)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func TestKey_HomeIsExpandedInKeyFile(t *testing.T) {
|
|
260
|
+
e := newEnv(t)
|
|
261
|
+
home := os.Getenv("HOME")
|
|
262
|
+
if err := os.WriteFile(filepath.Join(home, "k.txt"), []byte("home-key-00000"), 0o600); err != nil {
|
|
263
|
+
t.Fatal(err)
|
|
264
|
+
}
|
|
265
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
266
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"apiKeyFile": "~/k.txt"}))
|
|
267
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
268
|
+
h.toolCall("bash", bash("x"))
|
|
269
|
+
if srv.count() != 1 || srv.first(t).Auth != "Bearer home-key-00000" {
|
|
270
|
+
t.Errorf("requests = %+v", srv.requests())
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func TestKey_MissingKeyWarnsOnceAndStaysInactive(t *testing.T) {
|
|
275
|
+
e := newEnv(t)
|
|
276
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
277
|
+
e.writeGlobal(t, jevConfig(srv, nil))
|
|
278
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
279
|
+
h.Fire("session_start", map[string]any{"reason": "reload"})
|
|
280
|
+
n := 0
|
|
281
|
+
for _, note := range h.notifications() {
|
|
282
|
+
if strings.Contains(note, "no key. Set TYPESAFE_API_KEY or apiKeyFile in pi-jev.json; the gate is inactive until then.") {
|
|
283
|
+
n++
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if n != 1 {
|
|
287
|
+
t.Errorf("missing-key warnings = %d, want 1: %q", n, h.notifications())
|
|
288
|
+
}
|
|
289
|
+
h.toolCall("bash", bash("x"))
|
|
290
|
+
if srv.count() != 0 {
|
|
291
|
+
t.Error("judged without a key")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func TestConfig_UnreadableAndInvalidFilesWarn(t *testing.T) {
|
|
296
|
+
e := newEnv(t)
|
|
297
|
+
if err := os.WriteFile(filepath.Join(e.agent, "pi-jev.json"), []byte("{nope"), 0o600); err != nil {
|
|
298
|
+
t.Fatal(err)
|
|
299
|
+
}
|
|
300
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
301
|
+
if !h.anyNotification("invalid JSON") {
|
|
302
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func TestConfig_WarningsRedactTheKey(t *testing.T) {
|
|
307
|
+
e := newEnv(t)
|
|
308
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
309
|
+
srv := newFakeJev(t, always(clearGate()))
|
|
310
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{"apiKeyFile": "/nonexistent/" + testKey}))
|
|
311
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
312
|
+
for _, n := range h.notifications() {
|
|
313
|
+
if strings.Contains(n, testKey) {
|
|
314
|
+
t.Errorf("key in warning: %q", n)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func TestConfig_InvalidValuesFallBackToDefaults(t *testing.T) {
|
|
320
|
+
e := newEnv(t)
|
|
321
|
+
t.Setenv("TYPESAFE_API_KEY", testKey)
|
|
322
|
+
srv := newFakeJev(t, always(gateBody(0.95, 0, 0, 0, 0.9)))
|
|
323
|
+
e.writeGlobal(t, jevConfig(srv, map[string]any{
|
|
324
|
+
"gate": map[string]any{"mode": "loud", "argumentChars": -4, "cacheSeconds": 1.5, "blockOn": map[string]any{"destructive": 7, "impact": -1}},
|
|
325
|
+
}))
|
|
326
|
+
h := start(t, e, newHostState(), HostOptions{})
|
|
327
|
+
h.toolCall("bash", bash("x"))
|
|
328
|
+
// mode stays shadow, destructive threshold stays 0.9.
|
|
329
|
+
if got := h.lastStatus(); got != "jev: destructive 0.95" {
|
|
330
|
+
t.Errorf("status = %q", got)
|
|
331
|
+
}
|
|
332
|
+
if !h.anyNotification("jev shadow: bash - destructive 0.95") {
|
|
333
|
+
t.Errorf("notifications = %q", h.notifications())
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
package jev
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"math"
|
|
6
|
+
"sort"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// A question, in the shape of the original's JevQuestion. Questions travel as an
|
|
14
|
+
// ordered list because the wire order is part of the request.
|
|
15
|
+
type question struct {
|
|
16
|
+
ID string
|
|
17
|
+
Type string // noul, choice, score
|
|
18
|
+
Instructions string
|
|
19
|
+
// noul
|
|
20
|
+
CriteriaTrue, CriteriaFalse string
|
|
21
|
+
// choice: option names in order, description nil for "no detail".
|
|
22
|
+
Options []option
|
|
23
|
+
// score: ordered rubric levels, lowest first.
|
|
24
|
+
Levels []string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type option struct {
|
|
28
|
+
Name string
|
|
29
|
+
Description *string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func desc(s string) *string { return &s }
|
|
33
|
+
|
|
34
|
+
// answer is one typed answer.
|
|
35
|
+
type answer struct {
|
|
36
|
+
Type string
|
|
37
|
+
Noul float64
|
|
38
|
+
Choice string
|
|
39
|
+
Score float64
|
|
40
|
+
Confidence float64
|
|
41
|
+
HasConfidence bool
|
|
42
|
+
Probabilities []probability // choice or score, sorted by name
|
|
43
|
+
Legend map[string]any // score: rubric text by level, as the judge sent it
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type probability struct {
|
|
47
|
+
Name string
|
|
48
|
+
Value float64
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type usage struct {
|
|
52
|
+
Input, Output float64
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type response struct {
|
|
56
|
+
Model string
|
|
57
|
+
Answers map[string]answer
|
|
58
|
+
Usage *usage
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// fromResult validates a judge's result against the asked questions and converts
|
|
62
|
+
// it to the extension's answer types. A result that skips a question, answers with
|
|
63
|
+
// another type or a value outside its range is an error, never a zero (0.2.2 fix,
|
|
64
|
+
// and PORT.md C5).
|
|
65
|
+
func fromResult(res *typesafe.SystemOneResult, qs []question) (*response, error) {
|
|
66
|
+
if res == nil {
|
|
67
|
+
return nil, fmt.Errorf("response was not an object")
|
|
68
|
+
}
|
|
69
|
+
if res.Answers == nil {
|
|
70
|
+
return nil, fmt.Errorf("response is missing the answers map")
|
|
71
|
+
}
|
|
72
|
+
out := &response{Model: res.Model, Answers: map[string]answer{}}
|
|
73
|
+
if res.Usage.InputTokens != 0 || res.Usage.OutputTokens != 0 {
|
|
74
|
+
out.Usage = &usage{Input: float64(res.Usage.InputTokens), Output: float64(res.Usage.OutputTokens)}
|
|
75
|
+
}
|
|
76
|
+
for _, q := range qs {
|
|
77
|
+
raw, present := res.Answers[q.ID]
|
|
78
|
+
if !present {
|
|
79
|
+
return nil, fmt.Errorf("response did not answer %q", q.ID)
|
|
80
|
+
}
|
|
81
|
+
a, err := convertAnswer(q, raw)
|
|
82
|
+
if err != nil {
|
|
83
|
+
return nil, err
|
|
84
|
+
}
|
|
85
|
+
out.Answers[q.ID] = a
|
|
86
|
+
}
|
|
87
|
+
return out, nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func inRatio(v float64) bool { return !math.IsNaN(v) && v >= 0 && v <= 1 }
|
|
91
|
+
|
|
92
|
+
func convertAnswer(q question, raw typesafe.Answer) (answer, error) {
|
|
93
|
+
if raw == nil || string(raw.AnswerType()) != q.Type {
|
|
94
|
+
return answer{}, fmt.Errorf("answer %q is not a %s", q.ID, q.Type)
|
|
95
|
+
}
|
|
96
|
+
a := answer{Type: q.Type}
|
|
97
|
+
switch t := raw.(type) {
|
|
98
|
+
case typesafe.NoulAnswer:
|
|
99
|
+
if !inRatio(t.Noul) {
|
|
100
|
+
return answer{}, fmt.Errorf("answer %q noul %v is outside 0 to 1", q.ID, t.Noul)
|
|
101
|
+
}
|
|
102
|
+
a.Noul = t.Noul
|
|
103
|
+
case typesafe.ChoiceAnswer:
|
|
104
|
+
if !inRatio(t.Confidence) {
|
|
105
|
+
return answer{}, fmt.Errorf("answer %q confidence %v is outside 0 to 1", q.ID, t.Confidence)
|
|
106
|
+
}
|
|
107
|
+
found := false
|
|
108
|
+
for _, o := range q.Options {
|
|
109
|
+
found = found || o.Name == t.Choice
|
|
110
|
+
}
|
|
111
|
+
if !found {
|
|
112
|
+
return answer{}, fmt.Errorf("answer %q chose %q, which is not one of its options", q.ID, t.Choice)
|
|
113
|
+
}
|
|
114
|
+
a.Choice, a.Confidence, a.HasConfidence = t.Choice, t.Confidence, true
|
|
115
|
+
for _, k := range sortedKeys(t.Probabilities) {
|
|
116
|
+
a.Probabilities = append(a.Probabilities, probability{k, t.Probabilities[k]})
|
|
117
|
+
}
|
|
118
|
+
case typesafe.ScoreAnswer:
|
|
119
|
+
if !inRatio(t.Confidence) {
|
|
120
|
+
return answer{}, fmt.Errorf("answer %q confidence %v is outside 0 to 1", q.ID, t.Confidence)
|
|
121
|
+
}
|
|
122
|
+
if math.IsNaN(t.Score) || t.Score < 0 || t.Score > float64(len(q.Levels)-1) {
|
|
123
|
+
return answer{}, fmt.Errorf("answer %q score %v is outside the %d levels", q.ID, t.Score, len(q.Levels))
|
|
124
|
+
}
|
|
125
|
+
a.Score, a.Confidence, a.HasConfidence = t.Score, t.Confidence, true
|
|
126
|
+
a.Legend = map[string]any{}
|
|
127
|
+
for k, v := range t.Legend {
|
|
128
|
+
a.Legend[strconv.Itoa(k)] = v
|
|
129
|
+
}
|
|
130
|
+
for k, v := range t.Probabilities {
|
|
131
|
+
a.Probabilities = append(a.Probabilities, probability{strconv.Itoa(k), v})
|
|
132
|
+
}
|
|
133
|
+
sort.Slice(a.Probabilities, func(i, j int) bool { return a.Probabilities[i].Name < a.Probabilities[j].Name })
|
|
134
|
+
default:
|
|
135
|
+
return answer{}, fmt.Errorf("answer %q has an unknown shape", q.ID)
|
|
136
|
+
}
|
|
137
|
+
return a, nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// toTypeSafe converts the questions to the shared client's, in order.
|
|
141
|
+
func toTypeSafe(qs []question) typesafe.Questions {
|
|
142
|
+
out := make(typesafe.Questions, 0, len(qs))
|
|
143
|
+
for _, q := range qs {
|
|
144
|
+
switch q.Type {
|
|
145
|
+
case "noul":
|
|
146
|
+
nq := typesafe.Noul(q.Instructions)
|
|
147
|
+
if q.CriteriaTrue != "" || q.CriteriaFalse != "" {
|
|
148
|
+
nq = nq.Yes(q.CriteriaTrue).No(q.CriteriaFalse)
|
|
149
|
+
}
|
|
150
|
+
out = append(out, typesafe.Ask(q.ID, nq))
|
|
151
|
+
case "choice":
|
|
152
|
+
opts := make([]typesafe.Option, len(q.Options))
|
|
153
|
+
for i, o := range q.Options {
|
|
154
|
+
var d any
|
|
155
|
+
if o.Description != nil {
|
|
156
|
+
d = *o.Description
|
|
157
|
+
}
|
|
158
|
+
opts[i] = typesafe.Opt(o.Name, d)
|
|
159
|
+
}
|
|
160
|
+
out = append(out, typesafe.Ask(q.ID, typesafe.Choice(q.Instructions, opts...)))
|
|
161
|
+
case "score":
|
|
162
|
+
levels := make([]any, len(q.Levels))
|
|
163
|
+
for i, l := range q.Levels {
|
|
164
|
+
levels[i] = l
|
|
165
|
+
}
|
|
166
|
+
out = append(out, typesafe.Ask(q.ID, typesafe.Score(q.Instructions, levels...)))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// describeAnswer is the compact one-line rendering for notifications.
|
|
173
|
+
func describeAnswer(a *answer) string {
|
|
174
|
+
if a == nil {
|
|
175
|
+
return "no answer"
|
|
176
|
+
}
|
|
177
|
+
switch a.Type {
|
|
178
|
+
case "noul":
|
|
179
|
+
return "yes " + toFixed2(a.Noul)
|
|
180
|
+
case "choice":
|
|
181
|
+
return fmt.Sprintf("%s (conf %s)", a.Choice, toFixed2(a.Confidence))
|
|
182
|
+
}
|
|
183
|
+
levels := len(a.Legend) - 1
|
|
184
|
+
suffix := ""
|
|
185
|
+
if levels > 0 {
|
|
186
|
+
suffix = fmt.Sprintf("/%d", levels)
|
|
187
|
+
}
|
|
188
|
+
return fmt.Sprintf("%s%s (conf %s)", toFixed2(a.Score), suffix, toFixed2(a.Confidence))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// describeAnswers is the one-line dump for /jev last.
|
|
192
|
+
func describeAnswers(r *response, qs []question) string {
|
|
193
|
+
parts := make([]string, 0, len(qs))
|
|
194
|
+
for _, q := range qs {
|
|
195
|
+
a := r.Answers[q.ID]
|
|
196
|
+
parts = append(parts, q.ID+"="+describeAnswer(&a))
|
|
197
|
+
}
|
|
198
|
+
return strings.Join(parts, " ")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func formatChoice(a answer) string {
|
|
202
|
+
ranked := append([]probability(nil), a.Probabilities...)
|
|
203
|
+
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].Value > ranked[j].Value })
|
|
204
|
+
parts := make([]string, len(ranked))
|
|
205
|
+
for i, p := range ranked {
|
|
206
|
+
parts[i] = p.Name + " " + toFixed2(p.Value)
|
|
207
|
+
}
|
|
208
|
+
return fmt.Sprintf("%s (conf %s) [%s]", a.Choice, toFixed2(a.Confidence), strings.Join(parts, ", "))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// validateQuestions fails early with a useful message instead of paying for a 422.
|
|
212
|
+
func validateQuestions(qs []question) error {
|
|
213
|
+
if len(qs) == 0 {
|
|
214
|
+
return fmt.Errorf("no questions provided")
|
|
215
|
+
}
|
|
216
|
+
seen := map[string]bool{}
|
|
217
|
+
for _, q := range qs {
|
|
218
|
+
if strings.TrimSpace(q.Instructions) == "" {
|
|
219
|
+
return fmt.Errorf("question %q: instructions are required", q.ID)
|
|
220
|
+
}
|
|
221
|
+
if seen[q.ID] {
|
|
222
|
+
return fmt.Errorf("duplicate question id %q", q.ID)
|
|
223
|
+
}
|
|
224
|
+
seen[q.ID] = true
|
|
225
|
+
}
|
|
226
|
+
return nil
|
|
227
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# CONTRACT: the shared Go TypeSafe client
|
|
2
|
+
|
|
3
|
+
Status: **implemented.** The signatures in `libraries/*` were frozen first so that the extensions built on
|
|
4
|
+
this client (`pi-typesafe`, `jev`, `warden`) could write their tests against them; the implementation has
|
|
5
|
+
since landed and those Packages use it. Changes are listed under "Changes since the CONTRACT commit" below.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
The Package is a library Package (root `go.mod`, no extension of its own), like `pig-play`. It uses the
|
|
10
|
+
standard library only.
|
|
11
|
+
|
|
12
|
+
| Import path | Role |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe` | The client: port of `@typesafe-ai/sdk` 0.6.0 (questions, answers, errors, retries, timeouts, logging, models, `Evaluator`, `EvaluateBatch`). |
|
|
15
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel` | The second backend: answers the same questions with a `Model` you give it (port of system-one-adapter-python). Implements `typesafe.Evaluator`. |
|
|
16
|
+
| `github.com/MichaelKinsy/pigpen/components/typesafe/libraries/pigmodel` | A `ownmodel.Model` on the PiG Go SDK model access (`Context.ModelRegistry()`); the model PiG is configured with. |
|
|
17
|
+
|
|
18
|
+
Use it from an extension the way `angry-pigs` uses `pig-play`: the extension's `go.mod` requires
|
|
19
|
+
`github.com/MichaelKinsy/pigpen/components/typesafe v0.0.0` (no `replace`), and its directory has a
|
|
20
|
+
`go.work` with `use ( . ../../../typesafe )`; a Piglet selects the Package under an alias equal to its
|
|
21
|
+
directory name (`typesafe`). See `components/extension-port/skills/pigpen-pi-extension-port/SKILL.md`, step 8.
|
|
22
|
+
|
|
23
|
+
`npm run test:go-ports` picks up this Package's root-level `go.mod`, so it vets and tests it with the other
|
|
24
|
+
modules; `go test ./...` in this directory also works.
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
```go
|
|
29
|
+
// The TypeSafe API (TYPESAFE_API_KEY, TYPESAFE_BASE_URL, TYPESAFE_DEFAULT_MODEL, TYPESAFE_LOG_LEVEL).
|
|
30
|
+
client, err := typesafe.NewClient(typesafe.Config{})
|
|
31
|
+
res, err := client.SystemOne(ctx, typesafe.SystemOneRequest{
|
|
32
|
+
State: typesafe.Text("I was charged twice. Please fix this ASAP."),
|
|
33
|
+
Questions: typesafe.Questions{
|
|
34
|
+
typesafe.Ask("billing", typesafe.Noul("Is this about billing?")),
|
|
35
|
+
typesafe.Ask("tone", typesafe.Choice("Tone?", typesafe.Opt("calm", nil), typesafe.Opt("angry", nil))),
|
|
36
|
+
typesafe.Ask("urgency", typesafe.Score("How urgent?", "can wait", "this week", "today")),
|
|
37
|
+
},
|
|
38
|
+
}, nil)
|
|
39
|
+
tone, err := res.Choice("tone") // typed: ChoiceAnswer{Choice, Confidence, Probabilities}
|
|
40
|
+
|
|
41
|
+
// The own model: whatever PiG is configured with.
|
|
42
|
+
info, _ := ctx.GetModelInfo() // PiG Go SDK
|
|
43
|
+
m, _ := pigmodel.New(ctx.ModelRegistry(), pigmodel.Ref{Provider: info.Provider, ID: info.ID})
|
|
44
|
+
own, _ := ownmodel.New(ownmodel.Options{Model: m, AnswerMode: ownmodel.Probabilities})
|
|
45
|
+
|
|
46
|
+
var ev typesafe.Evaluator = own // or client: same request, same result type
|
|
47
|
+
out := typesafe.EvaluateBatch(ctx, ev, items, typesafe.BatchOptions{Concurrency: 4})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The signatures with their documentation are in the source (`go doc` shows them); this document states
|
|
51
|
+
what the doc comments do not: the mapping from the TypeScript SDK and every deliberate difference.
|
|
52
|
+
|
|
53
|
+
## Semantics that are fixed
|
|
54
|
+
|
|
55
|
+
- **Wire format** is the TypeScript SDK's: `POST {baseURL}/v1/systemone` with body
|
|
56
|
+
`{state, questions, model, ...extra}`, `GET {baseURL}/v1/models`; headers `Authorization: Bearer <key>`,
|
|
57
|
+
`Accept: application/json`, `User-Agent`, `X-TypeSafe-SDK`, `X-TypeSafe-Runtime`, `Content-Type: application/json`
|
|
58
|
+
only when there is a body, and `X-TypeSafe-Retry-Count: <n>` on retries. Caller headers cannot replace
|
|
59
|
+
those (case-insensitive); per-call headers win over `DefaultHeaders`.
|
|
60
|
+
- **Omitted vs null** is preserved. `Entry{}` is omitted; `Null` and `Value(nil)` are null. `Noul(x)` sends
|
|
61
|
+
`instructions` (null when x is nil) and omits `criteria`; `.Yes`/`.No` set one side; `.NullCriteria()` sends null.
|
|
62
|
+
- **Validation before sending** (`*TypeSafeError`, texts as in the SDK): no questions ("At least one question is
|
|
63
|
+
required."), a score question with fewer than two criteria ("Score question "q" has N criteria; at least two
|
|
64
|
+
scores are required."), and, added for Go, duplicate names.
|
|
65
|
+
- **Retries**: default 2 retries after the first attempt; 500 ms initial backoff doubling to 5 s; jitter 0.25;
|
|
66
|
+
statuses 408, 429 and 500 to 599; `Retry-After` / `retry-after-ms` honored up to 60 s; connection failures and
|
|
67
|
+
timeouts retried. Cancellation is never retried. **Timeout** is per attempt (10 s), covers headers and body,
|
|
68
|
+
and there is no total budget.
|
|
69
|
+
- **Errors**: `errors.As` targets as drawn in the `TypeSafeError` doc. Messages are the SDK's, for example
|
|
70
|
+
`401 invalid api key`, `Request timed out after 1000ms.`, `No API key was provided...`.
|
|
71
|
+
- **Answers** decode by their `type`; `Noul`, `Choice`, `Score` accessors on the result return an error for a
|
|
72
|
+
missing or mismatched answer. A response with no answers is not an error.
|
|
73
|
+
- **Secrets**: the API key is never exposed by a method, `String`, `%+v`, `%#v`, JSON encoding or logs (the
|
|
74
|
+
`Authorization` header is redacted to `Bearer ***abcd`).
|
|
75
|
+
|
|
76
|
+
## Deliberate differences from the TypeScript SDK
|
|
77
|
+
|
|
78
|
+
| TypeScript | Go | Why |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `AbortSignal` in per-call options | the `context.Context` argument; ending it returns `*APIUserAbortError` | Go idiom |
|
|
81
|
+
| `fetch` option | `Config.HTTPClient` (`HTTPDoer`) | Go idiom |
|
|
82
|
+
| `dangerouslyAllowBrowser`, browser guard, `describeRuntime()` runtime names | not ported; `X-TypeSafe-Runtime` is `go/<version> (<os>; <arch>)` and `X-TypeSafe-SDK` is `typesafe-sdk-go/0.6.0` | no browser; an honest client identity |
|
|
83
|
+
| `APIPromise` (`asResponse`, `withResponse`, `map`, thenable) | `SystemOneWithResponse`, `SystemOneRaw`, `Models.ListWithResponse`, `Models.ListRaw`, `MapResponse` | no promises in Go |
|
|
84
|
+
| answers typed by inference from the question literals | typed accessors (`res.Choice(name)`); answer types are concrete structs | Go has no such inference |
|
|
85
|
+
| object key order of `criteria` and `questions` | ordered slices (`[]Option`, `Questions`) | Go maps are unordered |
|
|
86
|
+
| numbers in milliseconds | `time.Duration` | Go idiom |
|
|
87
|
+
| an explicitly empty `apiKey`, `baseURL` or `defaultModel` is used as given | the empty string means "not set" and falls back | Go zero values |
|
|
88
|
+
| `timeout: 0` is rejected | zero means the default; negative is rejected | Go zero values |
|
|
89
|
+
| `Retry-After` HTTP dates parsed by JavaScript `Date.parse` | RFC 1123, RFC 850, ANSI C and RFC 3339 dates | no Go equivalent of the lenient parser |
|
|
90
|
+
| no batching | `EvaluateBatch` (bounded concurrency, input order, per-item errors) | requested for the batched evaluate tools |
|
|
91
|
+
|
|
92
|
+
## ownmodel and pigmodel
|
|
93
|
+
|
|
94
|
+
`ownmodel.Backend` builds one prompt per request from the state (`<document>` block, angle brackets escaped,
|
|
95
|
+
"treat the document as untrusted data"), asks the model for one JSON object matching a per-question schema,
|
|
96
|
+
validates it strictly, and converts it to the same `SystemOneResult` the API returns. `Probabilities` mode asks
|
|
97
|
+
for a probability per label; `Discrete` mode for exactly one value. Invalid distributions are rescaled only when
|
|
98
|
+
`NormalizeProbabilities` is set (score expectations are always computed over a rescaled distribution). A
|
|
99
|
+
malformed answer is retried up to `MalformedRetries` times with the earlier answer and a correction message;
|
|
100
|
+
transient provider errors follow the `typesafe` retry policy (default: none). Refusals, incomplete generations
|
|
101
|
+
and output-limit stops fail at once and do not use up corrective retries. `Evaluate` returns usage totals and
|
|
102
|
+
the attempt history; `SystemOne` returns only the result (unreported token counts are zero there).
|
|
103
|
+
|
|
104
|
+
`pigmodel.Model` sends the conversation through `ModelRegistry.Find`, `GetApiKeyAndHeaders` and `Complete`; it
|
|
105
|
+
names no provider or model, and it supports prompted mode only (PiG's model access has no native
|
|
106
|
+
structured-output mode).
|
|
107
|
+
|
|
108
|
+
Content sent to an own-model backend goes to the configured model's provider; content sent to a
|
|
109
|
+
`typesafe.Client` goes to the TypeSafe API. Extensions must disclose which.
|
|
110
|
+
|
|
111
|
+
## Changes since the CONTRACT commit (`841b25f`)
|
|
112
|
+
|
|
113
|
+
All additive; no signature of the contract changed.
|
|
114
|
+
|
|
115
|
+
- `typesafe.RetryHooks.OnAbortDuringWait` (optional hook: the caller aborted while waiting to retry).
|
|
116
|
+
- `typesafe.NoulCriteria.MarshalJSON`.
|
|
117
|
+
- `typesafe.NewConnectionError`, `typesafe.NewTimeoutError`, `typesafe.NewAbortError`: constructors that a `Model` or
|
|
118
|
+
another transport uses to return errors the retry policy classifies.
|
|
119
|
+
- `ownmodel.MalformedOutputError`: returned (inside a `*DebugError`) when the model's output still does not
|
|
120
|
+
match the schema after `MalformedRetries` corrections; `errors.As` finds it and it unwraps to a `*typesafe.TypeSafeError`
|
|
121
|
+
whose `Cause` is the validation error.
|
|
122
|
+
- Request bodies are encoded without HTML escaping (`<`, `>`, `&` as written), like the official SDK.
|
|
123
|
+
- (Review) `ParseRetryAfter`, `RetryPolicy.Delay` and `RateLimitError.RetryAfter` saturate at the largest
|
|
124
|
+
`time.Duration` instead of wrapping to a negative value; a huge `Retry-After` now falls back to backoff like the SDK.
|
|
125
|
+
|