@pi-in-go/pigpen-pi-typesafe 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 +14 -0
- package/LICENSE +22 -0
- package/README.md +45 -0
- package/extensions/pi-typesafe/branches_test.go +185 -0
- package/extensions/pi-typesafe/command.go +319 -0
- package/extensions/pi-typesafe/export_test.go +9 -0
- package/extensions/pi-typesafe/extension.go +188 -0
- package/extensions/pi-typesafe/extension_test.go +321 -0
- package/extensions/pi-typesafe/fakehost_test.go +548 -0
- package/extensions/pi-typesafe/format.go +191 -0
- package/extensions/pi-typesafe/format_test.go +75 -0
- package/extensions/pi-typesafe/go.mod +10 -0
- package/extensions/pi-typesafe/go.sum +2 -0
- package/extensions/pi-typesafe/go.work +11 -0
- package/extensions/pi-typesafe/harness_test.go +200 -0
- package/extensions/pi-typesafe/ownmodel_test.go +100 -0
- package/extensions/pi-typesafe/review_test.go +134 -0
- package/extensions/pi-typesafe/tool.go +193 -0
- package/extensions/pi-typesafe/twin_test.go +28 -0
- package/libs/pi-typesafe-api/CREDITS.md +14 -0
- package/libs/pi-typesafe-api/LICENSE +22 -0
- package/libs/pi-typesafe-api/README.md +30 -0
- package/libs/pi-typesafe-api/ask.go +62 -0
- package/libs/pi-typesafe-api/ask_test.go +76 -0
- package/libs/pi-typesafe-api/auth.go +249 -0
- package/libs/pi-typesafe-api/auth_test.go +131 -0
- package/libs/pi-typesafe-api/backends.go +336 -0
- package/libs/pi-typesafe-api/backends_test.go +404 -0
- package/libs/pi-typesafe-api/batch.go +202 -0
- package/libs/pi-typesafe-api/batch_test.go +202 -0
- package/libs/pi-typesafe-api/battery_test.go +41 -0
- package/libs/pi-typesafe-api/calibrate.go +354 -0
- package/libs/pi-typesafe-api/calibrate_test.go +186 -0
- package/libs/pi-typesafe-api/client.go +615 -0
- package/libs/pi-typesafe-api/client_test.go +490 -0
- package/libs/pi-typesafe-api/credentials.go +252 -0
- package/libs/pi-typesafe-api/credentials_test.go +216 -0
- package/libs/pi-typesafe-api/doc.go +14 -0
- package/libs/pi-typesafe-api/errors.go +143 -0
- package/libs/pi-typesafe-api/evaluation.go +86 -0
- package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
- package/libs/pi-typesafe-api/gaps_test.go +77 -0
- package/libs/pi-typesafe-api/go.mod +9 -0
- package/libs/pi-typesafe-api/go.sum +2 -0
- package/libs/pi-typesafe-api/helpers_test.go +169 -0
- package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
- package/libs/pi-typesafe-api/json.go +299 -0
- package/libs/pi-typesafe-api/json_test.go +92 -0
- package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
- package/libs/pi-typesafe-api/package.json +40 -0
- package/libs/pi-typesafe-api/provenance.json +18 -0
- package/libs/pi-typesafe-api/review_test.go +23 -0
- package/libs/pi-typesafe-api/schema.go +473 -0
- package/libs/pi-typesafe-api/schema_test.go +262 -0
- package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
- package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
- package/libs/pi-typesafe-api/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
- package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
- package/libs/pi-typesafe-api/ui/login.go +106 -0
- package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
- package/libs/pi-typesafe-api/usage.go +366 -0
- package/libs/pi-typesafe-api/usage_test.go +139 -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 +98 -0
- package/port/accepted-gaps.json +3 -0
- package/port/golden/enable-confirm.jsonl +11 -0
- package/port/golden/enable-decline.jsonl +20 -0
- package/port/golden/enable-missing-key.jsonl +4 -0
- package/port/golden/login-shadow.jsonl +4 -0
- package/port/golden/logout-env-key.jsonl +6 -0
- package/port/golden/playground-cancel.jsonl +4 -0
- package/port/golden/playground-invalid-json.jsonl +5 -0
- package/port/golden/playground-invalid-questions.jsonl +5 -0
- package/port/golden/status-env-key.jsonl +6 -0
- package/port/golden/status-no-key.jsonl +6 -0
- package/port/golden/tool-disabled.jsonl +18 -0
- package/port/golden/trailing-words.jsonl +10 -0
- package/port/library-mutations.py +44 -0
- package/port/mutations.json +302 -0
- package/port/oracle/.env.example +4 -0
- package/port/oracle/CHANGELOG.md +91 -0
- package/port/oracle/CONTRIBUTING.md +35 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +159 -0
- package/port/oracle/docs/api.md +143 -0
- package/port/oracle/docs/ci-cd.md +97 -0
- package/port/oracle/examples/decision-extension.ts +41 -0
- package/port/oracle/extensions/index.js +2 -0
- package/port/oracle/package.json +89 -0
- package/port/oracle/scripts/dev-pi.mjs +23 -0
- package/port/oracle/scripts/live-smoke.mjs +35 -0
- package/port/oracle/src/ask.ts +42 -0
- package/port/oracle/src/auth.ts +171 -0
- package/port/oracle/src/backends.ts +196 -0
- package/port/oracle/src/batch.ts +170 -0
- package/port/oracle/src/calibrate.ts +237 -0
- package/port/oracle/src/client.ts +310 -0
- package/port/oracle/src/credentials.ts +136 -0
- package/port/oracle/src/errors.ts +53 -0
- package/port/oracle/src/extension.ts +204 -0
- package/port/oracle/src/index.ts +31 -0
- package/port/oracle/src/key-prompt.ts +51 -0
- package/port/oracle/src/login.ts +60 -0
- package/port/oracle/src/schema.ts +158 -0
- package/port/oracle/src/ui.ts +4 -0
- package/port/oracle/src/usage.ts +258 -0
- package/port/oracle/tests/ask.test.ts +63 -0
- package/port/oracle/tests/auth.test.ts +141 -0
- package/port/oracle/tests/backends.test.ts +380 -0
- package/port/oracle/tests/batch.test.ts +156 -0
- package/port/oracle/tests/calibrate.test.ts +144 -0
- package/port/oracle/tests/client.test.ts +499 -0
- package/port/oracle/tests/credentials.test.ts +144 -0
- package/port/oracle/tests/extension.test.ts +276 -0
- package/port/oracle/tests/key-prompt.test.ts +47 -0
- package/port/oracle/tests/login.test.ts +101 -0
- package/port/oracle/tests/schema.test.ts +85 -0
- package/port/oracle/tests/usage.test.ts +106 -0
- package/port/oracle/tsconfig.build.json +10 -0
- package/port/oracle/tsconfig.json +14 -0
- package/port/scenarios/enable-confirm.json +5 -0
- package/port/scenarios/enable-decline.json +3 -0
- package/port/scenarios/enable-missing-key.json +2 -0
- package/port/scenarios/login-shadow.json +2 -0
- package/port/scenarios/logout-env-key.json +3 -0
- package/port/scenarios/playground-cancel.json +2 -0
- package/port/scenarios/playground-invalid-json.json +2 -0
- package/port/scenarios/playground-invalid-questions.json +2 -0
- package/port/scenarios/status-env-key.json +3 -0
- package/port/scenarios/status-no-key.json +3 -0
- package/port/scenarios/tool-disabled.json +2 -0
- package/port/scenarios/trailing-words.json +5 -0
- package/port/upstream-tests.json +160 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Package pi_typesafe is a Go port of pi-typesafe's extension half
|
|
2
|
+
// (https://github.com/DevMortimer/pi-typesafe, MIT, Ryan Gapac): the typesafe_evaluate tool that hands
|
|
3
|
+
// batched Choice, Score and Noul questions to Jev, the /typesafe command (login, consent, usage, sample
|
|
4
|
+
// test, JSON playground), and the result renderers. The typed API other extensions build on is the library
|
|
5
|
+
// package components/pi-typesafe.
|
|
6
|
+
//
|
|
7
|
+
// The tool is off until the operator runs /typesafe enable or sets PI_TYPESAFE_ENABLED=1. Content submitted
|
|
8
|
+
// goes to api.typesafe.ai, unless the own-model backend is selected (PI_TYPESAFE_BACKEND=ownmodel or
|
|
9
|
+
// /typesafe backend ownmodel): then it goes to the provider of the model PiG is configured with.
|
|
10
|
+
package pi_typesafe
|
|
11
|
+
|
|
12
|
+
import (
|
|
13
|
+
"context"
|
|
14
|
+
"os"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
|
|
18
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
19
|
+
pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
|
|
20
|
+
"github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/hostmodel"
|
|
21
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel"
|
|
22
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const (
|
|
26
|
+
toolName = "typesafe_evaluate"
|
|
27
|
+
entryType = "typesafe-result"
|
|
28
|
+
statusType = "typesafe-status"
|
|
29
|
+
envEnabled = "PI_TYPESAFE_ENABLED"
|
|
30
|
+
envBackend = "PI_TYPESAFE_BACKEND"
|
|
31
|
+
typesafeMsg = "Submitted state and questions will be sent to api.typesafe.ai and may incur charges. Do not include secrets. The extension does not collect files or conversation history. Results are model judgments, not proof or authorization."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Options inject the parts a test or an embedder replaces; the zero value is the production extension.
|
|
35
|
+
type Options struct {
|
|
36
|
+
// HTTPClient is the transport for the TypeSafe API.
|
|
37
|
+
HTTPClient typesafe.HTTPDoer
|
|
38
|
+
// Ledger is the usage ledger; nil uses the store next to the key.
|
|
39
|
+
Ledger pitypesafe.UsageLedger
|
|
40
|
+
// Evaluator replaces the own-model evaluator (which reads PiG's configured model).
|
|
41
|
+
Evaluator typesafe.Evaluator
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Extension returns the extension.
|
|
45
|
+
func Extension() *sdk.Extension { return New(Options{}) }
|
|
46
|
+
|
|
47
|
+
// state is one session's mutable configuration.
|
|
48
|
+
type state struct {
|
|
49
|
+
mu sync.Mutex
|
|
50
|
+
opts Options
|
|
51
|
+
ext *sdk.Extension
|
|
52
|
+
enabled bool
|
|
53
|
+
backend string
|
|
54
|
+
client *pitypesafe.TypeSafe
|
|
55
|
+
calledOut string
|
|
56
|
+
// consent counts backend switches. Consent is per destination: a call or a dialog that was admitted under
|
|
57
|
+
// one value must not be sent after it changed.
|
|
58
|
+
consent int
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// New returns the extension with injected parts.
|
|
62
|
+
func New(opts Options) *sdk.Extension {
|
|
63
|
+
s := &state{opts: opts, ext: sdk.New("pi-typesafe")}
|
|
64
|
+
s.enabled = os.Getenv(envEnabled) == "1"
|
|
65
|
+
s.backend = backendFromEnv()
|
|
66
|
+
s.registerTool()
|
|
67
|
+
s.ext.EntryRenderer(entryType, s.renderEntry)
|
|
68
|
+
s.ext.OnSessionStart(s.onSessionStart)
|
|
69
|
+
s.registerCommand()
|
|
70
|
+
return s.ext
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func backendFromEnv() string {
|
|
74
|
+
if strings.TrimSpace(os.Getenv(envBackend)) == pitypesafe.BackendOwnModel {
|
|
75
|
+
return pitypesafe.BackendOwnModel
|
|
76
|
+
}
|
|
77
|
+
return pitypesafe.BackendTypeSafe
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func (s *state) isEnabled() bool { s.mu.Lock(); defer s.mu.Unlock(); return s.enabled }
|
|
81
|
+
func (s *state) setEnabled(v bool) { s.mu.Lock(); s.enabled = v; s.mu.Unlock() }
|
|
82
|
+
func (s *state) currentBackend() string { s.mu.Lock(); defer s.mu.Unlock(); return s.backend }
|
|
83
|
+
|
|
84
|
+
// admit reads the consent gate and the destination it was given for under one lock.
|
|
85
|
+
func (s *state) admit() (enabled bool, consent int) {
|
|
86
|
+
s.mu.Lock()
|
|
87
|
+
defer s.mu.Unlock()
|
|
88
|
+
return s.enabled, s.consent
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// consentNow is the current destination's consent generation.
|
|
92
|
+
func (s *state) consentNow() int { s.mu.Lock(); defer s.mu.Unlock(); return s.consent }
|
|
93
|
+
|
|
94
|
+
// errDestinationChanged stops a call or a dialog whose destination changed after it was admitted.
|
|
95
|
+
var errDestinationChanged = &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "The judgment backend changed after this request was admitted; nothing was sent. Run /typesafe enable to consent to the new destination."}
|
|
96
|
+
|
|
97
|
+
// clientFor is getClient for a request admitted under consent generation consent: it fails with
|
|
98
|
+
// errDestinationChanged when a backend switch happened since, so the request cannot reach a destination
|
|
99
|
+
// nobody consented to. A switch after it returns does not affect the request (in-flight requests continue).
|
|
100
|
+
func (s *state) clientFor(consent int) (*pitypesafe.TypeSafe, error) {
|
|
101
|
+
s.mu.Lock()
|
|
102
|
+
defer s.mu.Unlock()
|
|
103
|
+
if s.consent != consent {
|
|
104
|
+
return nil, errDestinationChanged
|
|
105
|
+
}
|
|
106
|
+
return s.clientLocked()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// clientLocked builds the client on first use (s.mu held); a new session or a login clears it.
|
|
110
|
+
func (s *state) clientLocked() (*pitypesafe.TypeSafe, error) {
|
|
111
|
+
if s.client != nil {
|
|
112
|
+
return s.client, nil
|
|
113
|
+
}
|
|
114
|
+
options := pitypesafe.Options{Backend: s.backend, HTTPClient: s.opts.HTTPClient, Ledger: s.opts.Ledger}
|
|
115
|
+
if s.backend == pitypesafe.BackendOwnModel {
|
|
116
|
+
options.Evaluator = s.opts.Evaluator
|
|
117
|
+
if options.Evaluator == nil {
|
|
118
|
+
options.Evaluator = hostmodel.New(hostmodel.Options{AnswerMode: ownmodel.Probabilities, MalformedRetries: 1})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
client, err := pitypesafe.New(options)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return nil, err
|
|
124
|
+
}
|
|
125
|
+
s.client = client
|
|
126
|
+
return client, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func (s *state) resetClient() { s.mu.Lock(); s.client = nil; s.mu.Unlock() }
|
|
130
|
+
|
|
131
|
+
func (s *state) peekClient() *pitypesafe.TypeSafe { s.mu.Lock(); defer s.mu.Unlock(); return s.client }
|
|
132
|
+
|
|
133
|
+
// callOut says something once per distinct degradation per session: a long run must not bury the reason in
|
|
134
|
+
// repeated notices.
|
|
135
|
+
func (s *state) callOut(ctx sdk.Context, key, text string) {
|
|
136
|
+
s.mu.Lock()
|
|
137
|
+
if s.calledOut == key {
|
|
138
|
+
s.mu.Unlock()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
s.calledOut = key
|
|
142
|
+
s.mu.Unlock()
|
|
143
|
+
// Reporting must never replace the failure it describes, and a headless run may have no message channel.
|
|
144
|
+
if ctx.HasUI() {
|
|
145
|
+
ctx.Notify(text, "warning")
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
_ = ctx.SendMessage(statusType, text, true, sdk.SendMessageOptions{})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func (s *state) onSessionStart(ctx sdk.Context, _ map[string]any) (any, error) {
|
|
152
|
+
s.mu.Lock()
|
|
153
|
+
s.enabled = os.Getenv(envEnabled) == "1"
|
|
154
|
+
if backend := backendFromEnv(); backend != s.backend {
|
|
155
|
+
s.backend = backend
|
|
156
|
+
s.consent++
|
|
157
|
+
}
|
|
158
|
+
s.client = nil
|
|
159
|
+
s.calledOut = ""
|
|
160
|
+
backend := s.backend
|
|
161
|
+
s.mu.Unlock()
|
|
162
|
+
// An enabled extension with no usable key used to look exactly like a working one. Say it at startup; an
|
|
163
|
+
// unverified-but-present key stays quiet, because the first request is what proves it.
|
|
164
|
+
state, err := pitypesafe.GetAuthState(pitypesafe.AuthOptions{Backend: backend})
|
|
165
|
+
if err != nil {
|
|
166
|
+
return nil, nil
|
|
167
|
+
}
|
|
168
|
+
report, err := pitypesafe.DescribeAuth(state)
|
|
169
|
+
if err == nil && s.isEnabled() && report.Level == pitypesafe.LevelError {
|
|
170
|
+
s.callOut(ctx, "start:"+string(report.Level), "TypeSafe is enabled but judgments are skipped. "+report.Text)
|
|
171
|
+
}
|
|
172
|
+
return nil, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// goContext adapts a request Context to a context.Context that ends when the request is cancelled and carries
|
|
176
|
+
// the Context for the own-model backend.
|
|
177
|
+
func goContext(ctx sdk.Context) (context.Context, context.CancelFunc) {
|
|
178
|
+
c, cancel := context.WithCancel(hostmodel.WithContext(context.Background(), ctx))
|
|
179
|
+
done := ctx.Done()
|
|
180
|
+
go func() {
|
|
181
|
+
select {
|
|
182
|
+
case <-done:
|
|
183
|
+
cancel()
|
|
184
|
+
case <-c.Done():
|
|
185
|
+
}
|
|
186
|
+
}()
|
|
187
|
+
return c, cancel
|
|
188
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
package pi_typesafe_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"regexp"
|
|
9
|
+
"runtime"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
pi_typesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe/extensions/pi-typesafe"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func matches(pattern, s string) bool { return regexp.MustCompile(pattern).MatchString(s) }
|
|
17
|
+
|
|
18
|
+
func TestExtension(t *testing.T) {
|
|
19
|
+
r := newRig(t)
|
|
20
|
+
t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
|
|
21
|
+
storedPath := filepath.Join(r.agentDir, "pi-typesafe", "auth.json")
|
|
22
|
+
|
|
23
|
+
tw(t, "extension", "Pi loads a tool, a slash command, and a result renderer without network calls", func(t *testing.T) {
|
|
24
|
+
if r.network.Load() != 0 {
|
|
25
|
+
t.Fatal("registering must not touch the network")
|
|
26
|
+
}
|
|
27
|
+
if !r.host.tools["typesafe_evaluate"] || !r.host.cmds["typesafe"] {
|
|
28
|
+
t.Fatal("tool or command missing")
|
|
29
|
+
}
|
|
30
|
+
raw, failure := r.host.roundTrip(map[string]any{"method": "command_argument_completions", "tool": "typesafe", "args": json.RawMessage(`"pla"`)})
|
|
31
|
+
if failure != "" || !strings.Contains(string(raw), `"playground"`) {
|
|
32
|
+
t.Fatalf("completions = %s, %s", raw, failure)
|
|
33
|
+
}
|
|
34
|
+
guidelines := pi_typesafe.ToolGuidelines()
|
|
35
|
+
found := false
|
|
36
|
+
for _, g := range guidelines {
|
|
37
|
+
found = found || matches(`one question per item per dimension`, g)
|
|
38
|
+
}
|
|
39
|
+
if !found {
|
|
40
|
+
t.Error("the per-item guideline is missing")
|
|
41
|
+
}
|
|
42
|
+
// Models that never saw a payload author questions as an array; the guidelines must show one that actually validates.
|
|
43
|
+
var example string
|
|
44
|
+
for _, g := range guidelines {
|
|
45
|
+
if strings.Contains(g, `"state":`) {
|
|
46
|
+
example = g
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if example == "" || strings.Contains(example, "\n") || len(example) >= 1024 {
|
|
50
|
+
t.Fatalf("the example must exist on one short line: %q", example)
|
|
51
|
+
}
|
|
52
|
+
var payload map[string]any
|
|
53
|
+
if err := json.Unmarshal([]byte(example[strings.Index(example, "{"):]), &payload); err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
var kinds []string
|
|
57
|
+
for _, q := range payload["questions"].(map[string]any) {
|
|
58
|
+
kinds = append(kinds, q.(map[string]any)["type"].(string))
|
|
59
|
+
}
|
|
60
|
+
if len(kinds) != 3 || !strings.Contains(strings.Join(sortStrings(kinds), ","), "choice,noul,score") {
|
|
61
|
+
t.Errorf("kinds = %v", kinds)
|
|
62
|
+
}
|
|
63
|
+
if !matches(`named state field`, pi_typesafe.ToolDescription("typesafe")) {
|
|
64
|
+
t.Error("the description must teach named state fields")
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
tw(t, "extension", "default-disabled tool cannot submit data", func(t *testing.T) {
|
|
68
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) {
|
|
69
|
+
t.Fatalf("failure = %q", failure)
|
|
70
|
+
}
|
|
71
|
+
if r.network.Load() != 0 {
|
|
72
|
+
t.Fatal("a disabled tool must not submit")
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
tw(t, "extension", "setup and status never display the API key", func(t *testing.T) {
|
|
76
|
+
r.command("setup")
|
|
77
|
+
r.command("status")
|
|
78
|
+
found := false
|
|
79
|
+
for _, n := range r.noticesSince(0) {
|
|
80
|
+
found = found || strings.Contains(n, "TypeSafe key: TYPESAFE_API_KEY")
|
|
81
|
+
if strings.Contains(n, "offline-test-key") {
|
|
82
|
+
t.Fatalf("the key leaked: %s", n)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if !found {
|
|
86
|
+
t.Fatalf("notices = %v", r.noticesSince(0))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
tw(t, "extension", "status names the model the configured backend actually sends", func(t *testing.T) {
|
|
90
|
+
r.command("status")
|
|
91
|
+
if !strings.Contains(r.lastNotice(), "Model: jev-latest.") {
|
|
92
|
+
t.Fatalf("status = %s", r.lastNotice())
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
tw(t, "extension", "login refuses to shadow an environment key", func(t *testing.T) {
|
|
96
|
+
r.command("login")
|
|
97
|
+
if !strings.Contains(r.lastNotice(), "takes precedence") || r.modelList.Load() != 0 {
|
|
98
|
+
t.Fatalf("notice = %s", r.lastNotice())
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
tw(t, "extension", "declining consent keeps the tool disabled", func(t *testing.T) {
|
|
102
|
+
r.confirmResult = false
|
|
103
|
+
r.command("enable")
|
|
104
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) || r.network.Load() != 0 {
|
|
105
|
+
t.Fatalf("failure = %q", failure)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
tw(t, "extension", "explicit consent enables the real registered tool and returns structured results", func(t *testing.T) {
|
|
109
|
+
r.confirmResult = true
|
|
110
|
+
r.command("enable")
|
|
111
|
+
details, failure := r.tool(nil)
|
|
112
|
+
if failure != "" || yes(details) != 0.9 || r.network.Load() != 1 || r.confirmations < 2 {
|
|
113
|
+
t.Fatalf("details=%v failure=%q network=%d confirmations=%d", details, failure, r.network.Load(), r.confirmations)
|
|
114
|
+
}
|
|
115
|
+
// The renderer draws the result at every width without overflowing it.
|
|
116
|
+
detailsJSON, _ := json.Marshal(details)
|
|
117
|
+
for _, width := range []int{40, 80, 120} {
|
|
118
|
+
raw, failure := r.host.roundTrip(map[string]any{"method": "render_tool", "tool": "typesafe_evaluate", "args": mustJSON(map[string]any{
|
|
119
|
+
"card": "c1", "phase": "result", "width": width, "options": map[string]any{"expanded": true, "isPartial": false},
|
|
120
|
+
"result": map[string]any{"content": []any{}, "details": json.RawMessage(detailsJSON)},
|
|
121
|
+
})})
|
|
122
|
+
if failure != "" {
|
|
123
|
+
t.Fatalf("render failed: %s", failure)
|
|
124
|
+
}
|
|
125
|
+
var lines struct{ Lines []string }
|
|
126
|
+
_ = json.Unmarshal(raw, &lines)
|
|
127
|
+
for _, l := range lines.Lines {
|
|
128
|
+
if w := len([]rune(l)); w > width {
|
|
129
|
+
t.Errorf("width %d: line of %d cells: %q", width, w, l)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if !strings.Contains(strings.Join(lines.Lines, "\n"), "P(yes)") {
|
|
133
|
+
t.Errorf("width %d: no P(yes) in %v", width, lines.Lines)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
tw(t, "extension", "disable stops future calls without resetting usage", func(t *testing.T) {
|
|
138
|
+
r.command("disable")
|
|
139
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) {
|
|
140
|
+
t.Fatalf("failure = %q", failure)
|
|
141
|
+
}
|
|
142
|
+
r.command("status")
|
|
143
|
+
if !strings.Contains(r.lastNotice(), "1/20 attempts") {
|
|
144
|
+
t.Fatalf("status = %s", r.lastNotice())
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
tw(t, "extension", "invalid playground JSON and cancellation do not submit data", func(t *testing.T) {
|
|
148
|
+
r.editorText, r.editorOK = `{"broken":`, true
|
|
149
|
+
r.command("playground")
|
|
150
|
+
if !strings.Contains(r.lastNotice(), "Invalid JSON") {
|
|
151
|
+
t.Fatalf("notice = %s", r.lastNotice())
|
|
152
|
+
}
|
|
153
|
+
r.editorOK = false
|
|
154
|
+
r.command("playground")
|
|
155
|
+
if r.network.Load() != 1 {
|
|
156
|
+
t.Fatalf("network = %d", r.network.Load())
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
tw(t, "extension", "playground validates questions before requesting consent", func(t *testing.T) {
|
|
160
|
+
r.editorText, r.editorOK = `{"state":"example","questions":{}}`, true
|
|
161
|
+
prior := r.confirmations
|
|
162
|
+
r.command("playground")
|
|
163
|
+
if r.confirmations != prior || r.network.Load() != 1 || !strings.Contains(r.lastNotice(), "Invalid evaluation request") {
|
|
164
|
+
t.Fatalf("confirmations=%d network=%d notice=%s", r.confirmations, r.network.Load(), r.lastNotice())
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
tw(t, "extension", "test command requires confirmation and does not enable agent calls", func(t *testing.T) {
|
|
168
|
+
r.confirmResult = false
|
|
169
|
+
r.command("test")
|
|
170
|
+
if r.network.Load() != 1 {
|
|
171
|
+
t.Fatalf("network = %d", r.network.Load())
|
|
172
|
+
}
|
|
173
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) {
|
|
174
|
+
t.Fatalf("failure = %q", failure)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
tw(t, "extension", "login verifies, stores with owner-only permissions, and never echoes the key", func(t *testing.T) {
|
|
178
|
+
t.Setenv("TYPESAFE_API_KEY", "")
|
|
179
|
+
r.customResult = nil
|
|
180
|
+
r.command("login")
|
|
181
|
+
if !strings.Contains(r.lastNotice(), "cancelled") || fileExists(storedPath) {
|
|
182
|
+
t.Fatalf("cancel: %s", r.lastNotice())
|
|
183
|
+
}
|
|
184
|
+
r.customResult = "nope"
|
|
185
|
+
r.command("login")
|
|
186
|
+
if !strings.Contains(r.lastNotice(), "does not look like") || r.modelList.Load() != 0 || fileExists(storedPath) {
|
|
187
|
+
t.Fatalf("bad key: %s", r.lastNotice())
|
|
188
|
+
}
|
|
189
|
+
r.customResult = "ts_live_key_0123456789abcdef"
|
|
190
|
+
r.command("login")
|
|
191
|
+
if r.modelList.Load() != 1 || !strings.Contains(r.lastNotice(), "Key verified (1 model available)") {
|
|
192
|
+
t.Fatalf("login: %s", r.lastNotice())
|
|
193
|
+
}
|
|
194
|
+
for _, n := range r.noticesSince(0) {
|
|
195
|
+
if strings.Contains(n, "ts_live_key") {
|
|
196
|
+
t.Fatalf("the key leaked: %s", n)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if info, err := os.Stat(storedPath); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
|
|
200
|
+
t.Fatalf("stored file: %v %v", info, err)
|
|
201
|
+
}
|
|
202
|
+
r.command("status")
|
|
203
|
+
if !strings.Contains(r.lastNotice(), "TypeSafe key: /typesafe login") {
|
|
204
|
+
t.Fatalf("status: %s", r.lastNotice())
|
|
205
|
+
}
|
|
206
|
+
r.command("setup")
|
|
207
|
+
if !strings.Contains(r.lastNotice(), "configured via /typesafe login") {
|
|
208
|
+
t.Fatalf("setup: %s", r.lastNotice())
|
|
209
|
+
}
|
|
210
|
+
// The stored key powers the real tool after consent.
|
|
211
|
+
r.confirmResult = true
|
|
212
|
+
r.command("enable")
|
|
213
|
+
if _, failure := r.tool(nil); failure != "" || r.network.Load() != 2 {
|
|
214
|
+
t.Fatalf("tool: %q network=%d", failure, r.network.Load())
|
|
215
|
+
}
|
|
216
|
+
r.command("logout")
|
|
217
|
+
if fileExists(storedPath) {
|
|
218
|
+
t.Fatal("logout must delete the key")
|
|
219
|
+
}
|
|
220
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) {
|
|
221
|
+
t.Fatalf("failure = %q", failure)
|
|
222
|
+
}
|
|
223
|
+
r.command("status")
|
|
224
|
+
if !strings.Contains(r.lastNotice(), "TypeSafe key: missing") {
|
|
225
|
+
t.Fatalf("status: %s", r.lastNotice())
|
|
226
|
+
}
|
|
227
|
+
t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
|
|
228
|
+
})
|
|
229
|
+
tw(t, "extension", "new sessions reset opt-in; headless opt-in is explicit", func(t *testing.T) {
|
|
230
|
+
r.startSession("new")
|
|
231
|
+
if _, failure := r.tool(nil); !matches(`disabled`, failure) {
|
|
232
|
+
t.Fatalf("failure = %q", failure)
|
|
233
|
+
}
|
|
234
|
+
t.Setenv("PI_TYPESAFE_ENABLED", "1")
|
|
235
|
+
r.startSession("startup")
|
|
236
|
+
if _, failure := r.tool(nil); failure != "" || r.network.Load() != 3 {
|
|
237
|
+
t.Fatalf("failure=%q network=%d", failure, r.network.Load())
|
|
238
|
+
}
|
|
239
|
+
r.command("status")
|
|
240
|
+
if !strings.Contains(r.lastNotice(), "1/20 attempts") {
|
|
241
|
+
t.Fatalf("status: %s", r.lastNotice())
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
tw(t, "extension", "an enabled session with no key announces that judgments are skipped", func(t *testing.T) {
|
|
245
|
+
t.Setenv("TYPESAFE_API_KEY", "")
|
|
246
|
+
t.Setenv("PI_TYPESAFE_ENABLED", "1")
|
|
247
|
+
before := r.noticeCount()
|
|
248
|
+
r.startSession("startup")
|
|
249
|
+
said := strings.Join(r.noticesSince(before), "\n")
|
|
250
|
+
if !strings.Contains(said, "judgments are skipped") || !strings.Contains(said, "TypeSafe key: missing") {
|
|
251
|
+
t.Fatalf("said = %q", said)
|
|
252
|
+
}
|
|
253
|
+
// A key that appears later silences the next startup notice.
|
|
254
|
+
t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
|
|
255
|
+
again := r.noticeCount()
|
|
256
|
+
r.startSession("reload")
|
|
257
|
+
for _, n := range r.noticesSince(again) {
|
|
258
|
+
if strings.Contains(n, "judgments are skipped") {
|
|
259
|
+
t.Fatalf("stale callout: %s", n)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
tw(t, "extension", "a rejected key is called out once per session and shows up in status", func(t *testing.T) {
|
|
264
|
+
t.Setenv("PI_TYPESAFE_ENABLED", "1")
|
|
265
|
+
r.startSession("startup")
|
|
266
|
+
before := r.noticeCount()
|
|
267
|
+
r.respond = func(*http.Request) *http.Response {
|
|
268
|
+
return jsonResponse(401, map[string]any{"error": map[string]any{"message": "invalid key"}})
|
|
269
|
+
}
|
|
270
|
+
defer func() { r.respond = nil }()
|
|
271
|
+
for i := 0; i < 2; i++ {
|
|
272
|
+
if _, failure := r.tool(nil); !matches(`HTTP 401`, failure) {
|
|
273
|
+
t.Fatalf("failure = %q", failure)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Two failed calls, one callout: the reason is loud once, not once per call.
|
|
277
|
+
count := 0
|
|
278
|
+
for _, n := range r.noticesSince(before) {
|
|
279
|
+
if strings.Contains(n, "not authenticated") {
|
|
280
|
+
count++
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if count != 1 {
|
|
284
|
+
t.Fatalf("callouts = %d in %v", count, r.noticesSince(before))
|
|
285
|
+
}
|
|
286
|
+
r.command("status")
|
|
287
|
+
last := r.lastNotice()
|
|
288
|
+
if !strings.Contains(last, "was rejected") || !strings.Contains(last, "Today ") || !strings.Contains(last, "failed") {
|
|
289
|
+
t.Fatalf("status = %s", last)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
tw(t, "extension", "the registered tool admits the same near-miss aliases as the library", func(t *testing.T) {
|
|
293
|
+
t.Setenv("PI_TYPESAFE_ENABLED", "1")
|
|
294
|
+
r.startSession("startup")
|
|
295
|
+
before := r.network.Load()
|
|
296
|
+
details, failure := r.tool(map[string]any{"state": "synthetic", "questions": map[string]any{"yes": map[string]any{"type": "noul", "instructions": "Is this synthetic?", "criteria": "Is this synthetic data?"}}})
|
|
297
|
+
if failure != "" || r.network.Load() != before+1 || yes(details) != 0.9 {
|
|
298
|
+
t.Fatalf("failure=%q details=%v", failure, details)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func sortStrings(in []string) []string {
|
|
304
|
+
out := append([]string(nil), in...)
|
|
305
|
+
for i := range out {
|
|
306
|
+
for j := i + 1; j < len(out); j++ {
|
|
307
|
+
if out[j] < out[i] {
|
|
308
|
+
out[i], out[j] = out[j], out[i]
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func mustJSON(v any) json.RawMessage {
|
|
316
|
+
b, err := json.Marshal(v)
|
|
317
|
+
if err != nil {
|
|
318
|
+
panic(err)
|
|
319
|
+
}
|
|
320
|
+
return b
|
|
321
|
+
}
|