@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 +14 -0
- package/LICENSE +22 -0
- package/README.md +30 -0
- package/ask.go +62 -0
- package/ask_test.go +76 -0
- package/auth.go +249 -0
- package/auth_test.go +131 -0
- package/backends.go +336 -0
- package/backends_test.go +404 -0
- package/batch.go +202 -0
- package/batch_test.go +202 -0
- package/battery_test.go +41 -0
- package/calibrate.go +354 -0
- package/calibrate_test.go +186 -0
- package/client.go +615 -0
- package/client_test.go +490 -0
- package/credentials.go +252 -0
- package/credentials_test.go +216 -0
- package/doc.go +14 -0
- package/errors.go +143 -0
- package/evaluation.go +86 -0
- package/evaluation_schema.json +264 -0
- package/gaps_test.go +77 -0
- package/go.mod +9 -0
- package/go.sum +2 -0
- package/helpers_test.go +169 -0
- package/hostmodel/hostmodel.go +87 -0
- package/json.go +299 -0
- package/json_test.go +92 -0
- package/ownmodel_test.go +79 -0
- package/package.json +40 -0
- package/port/PORT.md +6 -0
- package/provenance.json +18 -0
- package/review_test.go +23 -0
- package/schema.go +473 -0
- package/schema_test.go +262 -0
- package/testdata/tools/typebox-messages.mts +5 -0
- package/testdata/typebox-messages.json +285 -0
- package/twin_test.go +28 -0
- package/ui/fakehost_test.go +548 -0
- package/ui/keyprompt.go +115 -0
- package/ui/login.go +106 -0
- package/ui/twin_test.go +28 -0
- package/ui/ui_test.go +285 -0
- package/usage.go +366 -0
- package/usage_test.go +139 -0
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
|
+
}
|