@pi-in-go/pigpen-pi-typesafe-api 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 ADDED
@@ -0,0 +1,14 @@
1
+ # Credits
2
+
3
+ This Package is a Go port of **pi-typesafe** 0.8.0, https://github.com/DevMortimer/pi-typesafe, by
4
+ **Ryan Gapac** (MIT, Copyright (c) 2026 Ryan Gapac). It is an independent project, not affiliated with TypeSafe AI or
5
+ the Pi authors, and so is this port.
6
+
7
+ - Pinned commit: `ed439f834665ad6fc652787f5ba7c23852566d49` (release 0.8.0, "Add Command Code backend and caller-supplied endpoints")
8
+ - The unmodified original, with its tests, is vendored in `components/pi-typesafe/port/oracle/`; its license is in `LICENSE`
9
+ of this Package and in `components/pi-typesafe/port/oracle/LICENSE`.
10
+ - What is modified: everything. The behavior, wording and structure follow the original; the code is a Go
11
+ implementation over the shared TypeSafe client (`components/typesafe`, a port of the official `@typesafe-ai/sdk`).
12
+ The mapping from each original file to its Go file is in `components/pi-typesafe/port/PORT.md`, with every deliberate difference.
13
+
14
+ The Go code, scenarios and tests were written for Pigpen by Michael Kinsy.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Gapac
4
+ Copyright (c) 2026 Michael Kinsy (the Go port)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # pi-typesafe-api (typed Go API)
2
+
3
+ The library half of the [pi-typesafe](../pi-typesafe/README.md) port: what an extension author imports to ask Jev typed
4
+ questions safely. Go module `github.com/MichaelKinsy/pigpen/components/pi-typesafe-api`, package `pitypesafe`, over the shared
5
+ client `components/typesafe`. A consumer extension's `go.mod` requires it at `v0.0.0` (no `replace`) and its directory has a
6
+ `go.work` with `use ( . ../../../pi-typesafe-api ../../../typesafe )`.
7
+
8
+ ```go
9
+ client, err := pitypesafe.New(pitypesafe.Options{MaxRequests: 5, MaxUSDPerDay: 1}) // key: TYPESAFE_API_KEY, else the login store
10
+ answer := pitypesafe.Ask(ctx, client, typesafe.SystemOneRequest{
11
+ State: typesafe.Text("I was charged twice."),
12
+ Questions: typesafe.Questions{typesafe.Ask("billing", typesafe.Noul("Is this about billing?"))},
13
+ }, pitypesafe.AskOptions{Timeout: 5 * time.Second})
14
+ if !answer.OK { /* answer.ErrorCode == pitypesafe.CodeBudget: stop asking; never an error value */ }
15
+ ```
16
+
17
+ | Piece | Where |
18
+ |---|---|
19
+ | Admission (`PrepareEvaluationRequest`: normalize model near-misses, validate, byte budget), `EvaluationSchema` | `schema.go`, `evaluation_schema.json` |
20
+ | Client with request budget, day caps, ledger, auth recording (`New`, `Evaluate`, `EvaluateRaw`, `ListModels`, `GetSpend`) | `client.go`, `usage.go`, `auth.go` |
21
+ | Backends: `typesafe`, `openrouter`, `commandcode`, a caller-supplied endpoint, and `ownmodel` | `backends.go` |
22
+ | Key store, key situations, `AgentDir` (PiG's directory) | `credentials.go` |
23
+ | `Ask`, `EvaluateMany`, `EvaluateAll`, `ChunkRequest`, `FanOut` | `ask.go`, `batch.go` |
24
+ | Calibration kit (`Calibrate`, `Replay`, `AUC`, ...) | `calibrate.go` |
25
+ | Own model on the PiG SDK's model access | `hostmodel/` |
26
+ | Hidden-input key prompt, `LoginWithPrompt`, `EnsureAPIKey` | `ui/` |
27
+
28
+ **Where content goes.** TypeSafe, OpenRouter, Command Code and custom-endpoint backends send it to that host
29
+ (`BackendHost` names it for consent text); the `ownmodel` backend sends it to the provider of the model PiG is configured with. A
30
+ key for one backend is never sent to another. Credit and license: [CREDITS.md](CREDITS.md). Mapping: [port/PORT.md](port/PORT.md).
package/ask.go ADDED
@@ -0,0 +1,62 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "time"
6
+
7
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
8
+ )
9
+
10
+ // Judge is anything with a client's Evaluate: the real client in a session, a stub in tests.
11
+ type Judge interface {
12
+ Evaluate(ctx context.Context, request typesafe.SystemOneRequest) (*Evaluation, error)
13
+ }
14
+
15
+ // DefaultAskTimeout is the default per-ask deadline; it matches the client's own request timeout.
16
+ const DefaultAskTimeout = 15 * time.Second
17
+
18
+ const askFallbackMessage = "TypeSafe request failed."
19
+
20
+ // AskOptions configure Ask.
21
+ type AskOptions struct {
22
+ // Timeout is the per-ask deadline, merged with the caller's own context. Default: DefaultAskTimeout.
23
+ Timeout time.Duration
24
+ }
25
+
26
+ // AskAnswer is the outcome of Ask.
27
+ type AskAnswer struct {
28
+ OK bool
29
+ // The fields below are set when OK.
30
+ Answers map[string]typesafe.Answer
31
+ Order []string
32
+ Model string
33
+ Usage typesafe.Usage
34
+ ElapsedMs int64
35
+ // Error and ErrorCode are set when not OK; ErrorCode is empty for a failure that is not an IntegrationError.
36
+ Error string
37
+ ErrorCode ErrorCode
38
+ }
39
+
40
+ // Ask is one typed Jev request that never fails: a failure comes back as an AskAnswer with OK false, this
41
+ // package's own message (which carries no upstream body, header, key, or submitted state) and its code, so a
42
+ // caller can stop asking after a budget error. The per-ask timeout is merged into the caller's context, so
43
+ // either can cancel the request.
44
+ //
45
+ // This is the author-facing "ask Jev" seam. Agents get the same admission through the typesafe_evaluate tool;
46
+ // the two share PrepareEvaluationRequest, so what one accepts the other accepts.
47
+ func Ask(ctx context.Context, judge Judge, request typesafe.SystemOneRequest, opts AskOptions) AskAnswer {
48
+ timeout := opts.Timeout
49
+ if timeout <= 0 {
50
+ timeout = DefaultAskTimeout
51
+ }
52
+ ctx, cancel := context.WithTimeout(ctx, timeout)
53
+ defer cancel()
54
+ result, err := judge.Evaluate(ctx, request)
55
+ if err != nil {
56
+ if ie, ok := err.(*IntegrationError); ok {
57
+ return AskAnswer{Error: ie.Message, ErrorCode: ie.Code}
58
+ }
59
+ return AskAnswer{Error: askFallbackMessage}
60
+ }
61
+ return AskAnswer{OK: true, Answers: result.Answers, Order: result.Order, Model: result.Model, Usage: result.Usage, ElapsedMs: result.ElapsedMs}
62
+ }
package/ask_test.go ADDED
@@ -0,0 +1,76 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "reflect"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
12
+ )
13
+
14
+ type judgeFunc func(ctx context.Context, r typesafe.SystemOneRequest) (*Evaluation, error)
15
+
16
+ func (f judgeFunc) Evaluate(ctx context.Context, r typesafe.SystemOneRequest) (*Evaluation, error) {
17
+ return f(ctx, r)
18
+ }
19
+
20
+ func TestAsk(t *testing.T) {
21
+ tw(t, "ask", "a successful ask returns the typed answers, model, usage, and duration", func(t *testing.T) {
22
+ judge := judgeFunc(func(context.Context, typesafe.SystemOneRequest) (*Evaluation, error) {
23
+ return &Evaluation{SystemOneResult: &typesafe.SystemOneResult{Model: "jev-test", Answers: map[string]typesafe.Answer{"yes": typesafe.NoulAnswer{Noul: 0.9}}, Usage: typesafe.Usage{InputTokens: 12}}, ElapsedMs: 7, Order: []string{"yes"}}, nil
24
+ })
25
+ a := Ask(context.Background(), judge, sampleRequest(), AskOptions{})
26
+ yes, _ := a.Answers["yes"].(typesafe.NoulAnswer)
27
+ if !a.OK || yes.Noul != 0.9 || a.Model != "jev-test" || a.Usage.InputTokens != 12 || a.ElapsedMs != 7 {
28
+ t.Fatalf("answer = %+v", a)
29
+ }
30
+ })
31
+ tw(t, "ask", "a typed failure comes back as data, with pi-typesafe's own code and message", func(t *testing.T) {
32
+ fail := func(err error) Judge {
33
+ return judgeFunc(func(context.Context, typesafe.SystemOneRequest) (*Evaluation, error) { return nil, err })
34
+ }
35
+ budget := Ask(context.Background(), fail(newError(CodeBudget, "TypeSafe request limit reached (1 attempts per client instance).")), sampleRequest(), AskOptions{})
36
+ if !reflect.DeepEqual(budget, AskAnswer{Error: "TypeSafe request limit reached (1 attempts per client instance).", ErrorCode: CodeBudget}) {
37
+ t.Fatalf("budget = %+v", budget)
38
+ }
39
+ e := &IntegrationError{Code: CodeHTTP, Message: "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made.", Status: 401}
40
+ if h := Ask(context.Background(), fail(e), sampleRequest(), AskOptions{}); h.OK || h.ErrorCode != CodeHTTP {
41
+ t.Fatalf("http = %+v", h)
42
+ }
43
+ })
44
+ tw(t, "ask", "an unknown failure is replaced by a fixed message so nothing from the transport escapes", func(t *testing.T) {
45
+ judge := judgeFunc(func(context.Context, typesafe.SystemOneRequest) (*Evaluation, error) {
46
+ return nil, errors.New("upstream body with a key: sk-secret")
47
+ })
48
+ a := Ask(context.Background(), judge, sampleRequest(), AskOptions{})
49
+ if !reflect.DeepEqual(a, AskAnswer{Error: "TypeSafe request failed."}) {
50
+ t.Fatalf("answer = %+v", a)
51
+ }
52
+ if data, _ := json.Marshal(a); contains(string(data), "sk-secret") {
53
+ t.Fatal("secret escaped")
54
+ }
55
+ })
56
+ tw(t, "ask", "the ask deadline and the caller's signal both cancel the request", func(t *testing.T) {
57
+ slow := judgeFunc(func(ctx context.Context, _ typesafe.SystemOneRequest) (*Evaluation, error) {
58
+ <-ctx.Done()
59
+ return nil, newError(CodeAborted, "TypeSafe request cancelled before submission.")
60
+ })
61
+ timedOut := Ask(context.Background(), slow, sampleRequest(), AskOptions{Timeout: 5 * time.Millisecond})
62
+ if !reflect.DeepEqual(timedOut, AskAnswer{Error: "TypeSafe request cancelled before submission.", ErrorCode: CodeAborted}) {
63
+ t.Fatalf("timed out = %+v", timedOut)
64
+ }
65
+ ctx, cancel := context.WithCancel(context.Background())
66
+ done := make(chan AskAnswer, 1)
67
+ go func() { done <- Ask(ctx, slow, sampleRequest(), AskOptions{Timeout: 5 * time.Second}) }()
68
+ cancel()
69
+ if a := <-done; a.OK {
70
+ t.Fatal("a cancelled ask must fail")
71
+ }
72
+ if DefaultAskTimeout != 15*time.Second {
73
+ t.Errorf("default = %v", DefaultAskTimeout)
74
+ }
75
+ })
76
+ }
package/auth.go ADDED
@@ -0,0 +1,249 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "time"
8
+ )
9
+
10
+ const authVersion = 1
11
+
12
+ // AuthFailure is the last request that degraded TypeSafe, with no upstream body, header, key, or submitted state.
13
+ type AuthFailure struct {
14
+ Code ErrorCode `json:"code"`
15
+ Status int `json:"status,omitempty"`
16
+ Message string `json:"message"`
17
+ At string `json:"at"`
18
+ }
19
+
20
+ // AuthState is the whole answer to "is Jev actually available right now": which key is in effect, whether it
21
+ // has been accepted, and the last failure that degraded it. Consumers must consult it instead of treating their
22
+ // own consent flag as proof that judgments will happen.
23
+ type AuthState struct {
24
+ // Backend is the value the caller passed (nil means the default).
25
+ Backend any
26
+ Kind KeyKind
27
+ Source KeySource
28
+ // Path is where the key would be read from.
29
+ Path string
30
+ // Reason says why a stored key cannot be used.
31
+ Reason string
32
+ // KeyName is a short label for the key source: TYPESAFE_API_KEY, /typesafe login, no key, unusable key.
33
+ KeyName string
34
+ // Verified is true when the key in effect was accepted by the backend.
35
+ Verified bool
36
+ VerifiedAt string
37
+ // LastFailure is cleared by the next successful request.
38
+ LastFailure *AuthFailure
39
+ // Usable means a key is present and the last authentication outcome was not a rejection. False means judgments are skipped.
40
+ Usable bool
41
+ }
42
+
43
+ // AuthOptions selects the state file and backend.
44
+ type AuthOptions struct {
45
+ Path string
46
+ Backend any
47
+ }
48
+
49
+ // AuthStatePath is the auth-state file: one small, owner-only record that outlives the process that wrote it.
50
+ func AuthStatePath() string { return filepath.Join(TypeSafeDir(), "auth-state.json") }
51
+
52
+ type storedAuth struct {
53
+ Version int `json:"version"`
54
+ VerifiedAt string `json:"verifiedAt,omitempty"`
55
+ LastFailure *AuthFailure `json:"lastFailure,omitempty"`
56
+ }
57
+
58
+ var errorCodes = map[ErrorCode]bool{CodeConfiguration: true, CodeValidation: true, CodeBudget: true, CodeAborted: true, CodeTimeout: true, CodeHTTP: true, CodeConnection: true, CodeResponse: true}
59
+
60
+ func readAuthState(path string) storedAuth {
61
+ data, err := os.ReadFile(path)
62
+ if err != nil {
63
+ return storedAuth{}
64
+ }
65
+ var raw struct {
66
+ VerifiedAt any `json:"verifiedAt"`
67
+ LastFailure *struct {
68
+ Code any `json:"code"`
69
+ Status any `json:"status"`
70
+ Message any `json:"message"`
71
+ At any `json:"at"`
72
+ } `json:"lastFailure"`
73
+ }
74
+ if json.Unmarshal(data, &raw) != nil {
75
+ return storedAuth{}
76
+ }
77
+ var out storedAuth
78
+ if v, ok := raw.VerifiedAt.(string); ok && utf16Len(v) <= 40 {
79
+ out.VerifiedAt = v
80
+ }
81
+ if f := raw.LastFailure; f != nil {
82
+ code, _ := f.Code.(string)
83
+ message, _ := f.Message.(string)
84
+ at, _ := f.At.(string)
85
+ if errorCodes[ErrorCode(code)] && message != "" && at != "" && utf16Len(at) <= 40 {
86
+ failure := &AuthFailure{Code: ErrorCode(code), Message: truncateUTF16(message, 300), At: at}
87
+ if s, ok := f.Status.(float64); ok && s == float64(int(s)) {
88
+ failure.Status = int(s)
89
+ }
90
+ out.LastFailure = failure
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ func truncateUTF16(s string, n int) string {
97
+ if utf16Len(s) <= n {
98
+ return s
99
+ }
100
+ count := 0
101
+ for i, r := range s {
102
+ w := 1
103
+ if r >= 0x10000 {
104
+ w = 2
105
+ }
106
+ if count+w > n {
107
+ return s[:i]
108
+ }
109
+ count += w
110
+ }
111
+ return s
112
+ }
113
+
114
+ // writeAuthState is owner-only, atomic, and best-effort: an unwritable auth record never changes how a request behaves.
115
+ func writeAuthState(path string, state storedAuth) {
116
+ state.Version = authVersion
117
+ body, err := json.MarshalIndent(state, "", " ")
118
+ if err != nil {
119
+ return
120
+ }
121
+ _ = writeOwnerOnly(path, append(body, '\n'))
122
+ }
123
+
124
+ func rejected(f *AuthFailure) bool {
125
+ return f != nil && f.Code == CodeHTTP && (f.Status == 401 || f.Status == 403)
126
+ }
127
+
128
+ // GetAuthState adds the key situation, the last outcome, and the clock up for a valid backend. An invalid
129
+ // backend returns the same configuration error as ResolveBackend, so validate a user-supplied endpoint with
130
+ // ResolveBackend first.
131
+ func GetAuthState(opts AuthOptions) (AuthState, error) {
132
+ path := opts.Path
133
+ if path == "" {
134
+ path = AuthStatePath()
135
+ }
136
+ backend := opts.Backend
137
+ if backend == nil {
138
+ backend = DefaultBackend
139
+ }
140
+ situation, err := KeySituationFor(backend)
141
+ if err != nil {
142
+ return AuthState{}, err
143
+ }
144
+ stored := readAuthState(path)
145
+ var source KeySource
146
+ switch situation.Kind {
147
+ case KeyEnvironment:
148
+ source = SourceEnvironment
149
+ case KeyStored:
150
+ source = SourceStored
151
+ }
152
+ isRejected := rejected(stored.LastFailure)
153
+ present := source != "" || situation.Kind == KeyNotRequired
154
+ state := AuthState{
155
+ Backend: backend,
156
+ Kind: situation.Kind,
157
+ Source: source,
158
+ Path: CredentialsPath(),
159
+ KeyName: KeySourceLabel(situation),
160
+ Verified: stored.VerifiedAt != "" && !isRejected,
161
+ VerifiedAt: stored.VerifiedAt,
162
+ LastFailure: stored.LastFailure,
163
+ Usable: present && !isRejected,
164
+ }
165
+ if situation.Kind == KeyUnusable {
166
+ state.Path = situation.Path
167
+ state.Reason = situation.Reason
168
+ }
169
+ return state, nil
170
+ }
171
+
172
+ // RecordAuthVerified records that the key was accepted: login verification, or any successful request. It clears the last failure.
173
+ func RecordAuthVerified(at time.Time) {
174
+ writeAuthState(AuthStatePath(), storedAuth{VerifiedAt: isoTime(at)})
175
+ }
176
+
177
+ // RecordAuthFailure records the failure that degraded TypeSafe. The verification timestamp is kept so a recovered key stays known.
178
+ func RecordAuthFailure(err *IntegrationError, at time.Time) {
179
+ current := readAuthState(AuthStatePath())
180
+ writeAuthState(AuthStatePath(), storedAuth{
181
+ VerifiedAt: current.VerifiedAt,
182
+ LastFailure: &AuthFailure{Code: err.Code, Status: err.Status, Message: err.Message, At: isoTime(at)},
183
+ })
184
+ }
185
+
186
+ // ClearAuthState forgets verification and degradation: used when the key itself changes (login or logout).
187
+ func ClearAuthState() { _ = os.Remove(AuthStatePath()) }
188
+
189
+ func isoTime(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z") }
190
+
191
+ // AuthLevel is the severity of an AuthReport.
192
+ type AuthLevel string
193
+
194
+ // The levels: error when judgments are skipped or were rejected, warning when the key is unverified, else ok.
195
+ const (
196
+ LevelOK AuthLevel = "ok"
197
+ LevelWarning AuthLevel = "warning"
198
+ LevelError AuthLevel = "error"
199
+ )
200
+
201
+ // AuthReport is one line plus a level, so a status command, a headless log, or a consumer's own status line
202
+ // can call out a degraded state instead of reporting "enabled".
203
+ type AuthReport struct {
204
+ Level AuthLevel
205
+ Text string
206
+ }
207
+
208
+ // DescribeAuth turns a state into a level and one safe line.
209
+ func DescribeAuth(state AuthState) (AuthReport, error) {
210
+ config, err := ResolveBackend(state.Backend)
211
+ if err != nil {
212
+ return AuthReport{}, err
213
+ }
214
+ label := config.Label + " key"
215
+ since := ""
216
+ if f := state.LastFailure; f != nil {
217
+ since = " Last failure: " + f.Message
218
+ if f.At != "" {
219
+ since += " (" + f.At + ")"
220
+ }
221
+ }
222
+ switch state.Kind {
223
+ case KeyNotRequired:
224
+ return AuthReport{LevelOK, config.Label + ": no key needed; judgments use the model PiG is configured with." + since}, nil
225
+ case KeyMissing:
226
+ how := config.KeyEnv + " is set in the environment"
227
+ if UsesTypeSafeKey(config.BackendConfig) {
228
+ how = "a key is configured (/typesafe login or " + typesafeKeyEnv + ")"
229
+ }
230
+ return AuthReport{LevelError, label + ": missing — every Jev judgment is skipped until " + how + "." + since}, nil
231
+ case KeyUnusable:
232
+ reason := state.Reason
233
+ if reason == "" {
234
+ reason = "unknown reason"
235
+ }
236
+ return AuthReport{LevelError, label + ": unusable (" + reason + ") — judgments are skipped until the key is fixed." + since}, nil
237
+ }
238
+ if rejected(state.LastFailure) {
239
+ return AuthReport{LevelError, label + ": " + state.KeyName + " was rejected." + since}, nil
240
+ }
241
+ if !state.Verified {
242
+ return AuthReport{LevelWarning, label + ": " + state.KeyName + " (not verified yet — the first request proves it)." + since}, nil
243
+ }
244
+ verified := "verified"
245
+ if state.VerifiedAt != "" {
246
+ verified += " " + state.VerifiedAt
247
+ }
248
+ return AuthReport{LevelOK, label + ": " + state.KeyName + " (" + verified + ")." + since}, nil
249
+ }
package/auth_test.go ADDED
@@ -0,0 +1,131 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "os"
5
+ "runtime"
6
+ "strings"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ func mustAuth(t *testing.T, backend any) AuthState {
12
+ t.Helper()
13
+ s, err := GetAuthState(AuthOptions{Backend: backend})
14
+ if err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ return s
18
+ }
19
+
20
+ func mustDescribe(t *testing.T, s AuthState) AuthReport {
21
+ t.Helper()
22
+ r, err := DescribeAuth(s)
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ return r
27
+ }
28
+
29
+ func TestAuth(t *testing.T) {
30
+ tw(t, "auth", "no key at all is an error the consumer cannot mistake for a working setup", func(t *testing.T) {
31
+ isolate(t)
32
+ s := mustAuth(t, nil)
33
+ if s.Kind != KeyMissing || s.KeyName != "no key" || s.Usable || s.Verified {
34
+ t.Fatalf("state = %+v", s)
35
+ }
36
+ r := mustDescribe(t, s)
37
+ if r.Level != LevelError || !strings.Contains(r.Text, "every Jev judgment is skipped") {
38
+ t.Fatalf("report = %+v", r)
39
+ }
40
+ })
41
+ tw(t, "auth", "another backend reports its own key and never the TypeSafe login hint", func(t *testing.T) {
42
+ isolate(t)
43
+ t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789abcdef")
44
+ if mustAuth(t, nil).Backend != "typesafe" {
45
+ t.Error("the default backend is typesafe")
46
+ }
47
+ missing := mustAuth(t, "openrouter")
48
+ if missing.Backend != "openrouter" || missing.Kind != KeyMissing || missing.Usable {
49
+ t.Fatalf("missing = %+v", missing)
50
+ }
51
+ r := mustDescribe(t, missing)
52
+ if r.Level != LevelError || !strings.HasPrefix(r.Text, "OpenRouter key: missing") || !strings.Contains(r.Text, "OPENROUTER_API_KEY is set") || strings.Contains(r.Text, "/typesafe login") {
53
+ t.Fatalf("report = %+v", r)
54
+ }
55
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-0123456789abcdef")
56
+ present := mustAuth(t, "openrouter")
57
+ if present.Kind != KeyEnvironment || present.KeyName != "OPENROUTER_API_KEY" || !present.Usable || !strings.HasPrefix(mustDescribe(t, present).Text, "OpenRouter key: OPENROUTER_API_KEY") {
58
+ t.Fatalf("present = %+v", present)
59
+ }
60
+ })
61
+ tw(t, "auth", "an environment key is usable but unverified until something proves it", func(t *testing.T) {
62
+ isolate(t)
63
+ t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789abcdef")
64
+ before := mustDescribe(t, mustAuth(t, nil))
65
+ if before.Level != LevelWarning || !strings.Contains(before.Text, "TYPESAFE_API_KEY") || !strings.Contains(before.Text, "not verified yet") {
66
+ t.Fatalf("before = %+v", before)
67
+ }
68
+ RecordAuthVerified(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
69
+ s := mustAuth(t, nil)
70
+ if !s.Usable || !s.Verified || s.VerifiedAt != "2026-01-01T00:00:00.000Z" || mustDescribe(t, s).Level != LevelOK {
71
+ t.Fatalf("state = %+v", s)
72
+ }
73
+ if info, _ := os.Stat(AuthStatePath()); runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
74
+ t.Errorf("mode = %v", info.Mode().Perm())
75
+ }
76
+ })
77
+ tw(t, "auth", "a rejected key degrades loudly and a later success clears it", func(t *testing.T) {
78
+ isolate(t)
79
+ _, _ = StoreAPIKey("stored-key-0123456789abcdef")
80
+ RecordAuthVerified(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC))
81
+ msg := "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made."
82
+ RecordAuthFailure(&IntegrationError{Code: CodeHTTP, Message: msg, Status: 401}, time.Date(2026, 1, 2, 1, 0, 0, 0, time.UTC))
83
+ d := mustAuth(t, nil)
84
+ want := AuthFailure{Code: CodeHTTP, Status: 401, Message: msg, At: "2026-01-02T01:00:00.000Z"}
85
+ if d.Kind != KeyStored || d.Usable || d.Verified || d.LastFailure == nil || *d.LastFailure != want {
86
+ t.Fatalf("degraded = %+v", d)
87
+ }
88
+ if r := mustDescribe(t, d); r.Level != LevelError || !strings.Contains(r.Text, "was rejected") {
89
+ t.Fatalf("report = %+v", r)
90
+ }
91
+ // A successful request proves the key again and drops the failure.
92
+ RecordAuthVerified(time.Date(2026, 1, 2, 2, 0, 0, 0, time.UTC))
93
+ if r := mustAuth(t, nil); !r.Usable || !r.Verified || r.LastFailure != nil {
94
+ t.Fatalf("recovered = %+v", r)
95
+ }
96
+ })
97
+ tw(t, "auth", "a non-authentication failure degrades the signal without pretending the key is gone", func(t *testing.T) {
98
+ isolate(t)
99
+ _, _ = StoreAPIKey("stored-key-0123456789abcdef")
100
+ RecordAuthVerified(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC))
101
+ RecordAuthFailure(&IntegrationError{Code: CodeTimeout, Message: "TypeSafe request timed out; it was not retried and may still be billed."}, time.Date(2026, 1, 3, 0, 5, 0, 0, time.UTC))
102
+ s := mustAuth(t, nil)
103
+ r := mustDescribe(t, s)
104
+ if !s.Usable || !s.Verified || r.Level != LevelOK || !strings.Contains(r.Text, "Last failure") {
105
+ t.Fatalf("state = %+v report = %+v", s, r)
106
+ }
107
+ })
108
+ tw(t, "auth", "a stored key readable by other users is reported as unusable, never as enabled", func(t *testing.T) {
109
+ if runtime.GOOS == "windows" {
110
+ t.Skip("POSIX permission bits")
111
+ }
112
+ isolate(t)
113
+ writeFile(t, CredentialsPath(), `{"apiKey":"stored-key-0123456789abcdef"}`, 0o644)
114
+ s := mustAuth(t, nil)
115
+ if s.Kind != KeyUnusable || s.Usable || s.Reason == "" || mustDescribe(t, s).Level != LevelError {
116
+ t.Fatalf("state = %+v", s)
117
+ }
118
+ })
119
+ tw(t, "auth", "a corrupt auth record is ignored and clearing forgets both facts", func(t *testing.T) {
120
+ isolate(t)
121
+ writeFile(t, AuthStatePath(), "{ not json", 0o600)
122
+ if s := mustAuth(t, nil); s.Verified || s.LastFailure != nil {
123
+ t.Fatalf("corrupt = %+v", s)
124
+ }
125
+ RecordAuthVerified(time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC))
126
+ ClearAuthState()
127
+ if s := mustAuth(t, nil); s.Verified || s.LastFailure != nil {
128
+ t.Fatalf("cleared = %+v", s)
129
+ }
130
+ })
131
+ }