@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,490 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"sync/atomic"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func testClient(t *testing.T, opts Options, doer typesafe.HTTPDoer) *TypeSafe {
|
|
19
|
+
t.Helper()
|
|
20
|
+
if opts.APIKey == "" && opts.Backend == nil {
|
|
21
|
+
opts.APIKey = "test-key"
|
|
22
|
+
}
|
|
23
|
+
if opts.HTTPClient == nil {
|
|
24
|
+
opts.HTTPClient = doer
|
|
25
|
+
}
|
|
26
|
+
c, err := New(opts)
|
|
27
|
+
if err != nil {
|
|
28
|
+
t.Fatal(err)
|
|
29
|
+
}
|
|
30
|
+
return c
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func failing(status int, header http.Header, calls *atomic.Int32) doerFunc {
|
|
34
|
+
return func(*http.Request) (*http.Response, error) {
|
|
35
|
+
if calls != nil {
|
|
36
|
+
calls.Add(1)
|
|
37
|
+
}
|
|
38
|
+
return jsonResponse(status, map[string]any{"error": map[string]any{"code": "x", "message": "never-print-me"}, "secret": "never-print-me"}, header), nil
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestClient(t *testing.T) {
|
|
43
|
+
tw(t, "client", "official SDK helpers, typed answers, metadata, and one batched network call", func(t *testing.T) {
|
|
44
|
+
isolate(t)
|
|
45
|
+
calls := 0
|
|
46
|
+
questions := typesafe.Questions{
|
|
47
|
+
typesafe.Ask("category", typesafe.Choice("Which?", typesafe.Opt("billing", "Charges"), typesafe.Opt("other", nil))),
|
|
48
|
+
typesafe.Ask("urgent", typesafe.Noul("Urgent?")),
|
|
49
|
+
typesafe.Ask("frustration", typesafe.Score("Frustration?", "Calm", "Angry")),
|
|
50
|
+
}
|
|
51
|
+
client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
52
|
+
calls++
|
|
53
|
+
body := requestBody(t, r)
|
|
54
|
+
var req struct {
|
|
55
|
+
Model string `json:"model"`
|
|
56
|
+
Questions json.RawMessage `json:"questions"`
|
|
57
|
+
}
|
|
58
|
+
_ = json.Unmarshal(body, &req)
|
|
59
|
+
want, _ := json.Marshal(questions)
|
|
60
|
+
if r.URL.String() != "https://api.typesafe.ai/v1/systemone" || r.Method != http.MethodPost || string(req.Questions) != string(want) || req.Model != "jev-latest" || !strings.Contains(r.Header.Get("Authorization"), "test-key") {
|
|
61
|
+
t.Errorf("url=%s method=%s model=%s questions=%s", r.URL, r.Method, req.Model, req.Questions)
|
|
62
|
+
}
|
|
63
|
+
return responseFor(body), nil
|
|
64
|
+
}))
|
|
65
|
+
result, err := client.Evaluate(context.Background(), typesafe.SystemOneRequest{State: typesafe.Value(map[string]any{"message": "Example"}), Questions: questions})
|
|
66
|
+
if err != nil {
|
|
67
|
+
t.Fatal(err)
|
|
68
|
+
}
|
|
69
|
+
category, _ := result.Choice("category")
|
|
70
|
+
urgent, _ := result.Noul("urgent")
|
|
71
|
+
frustration, _ := result.Score("frustration")
|
|
72
|
+
if category.Choice != "billing" || urgent.Noul != 0.9 || frustration.Score != 0 || calls != 1 || result.ElapsedMs < 0 {
|
|
73
|
+
t.Fatalf("answers = %+v %+v %+v calls=%d", category, urgent, frustration, calls)
|
|
74
|
+
}
|
|
75
|
+
if got := client.GetUsage(); got != (UsageSnapshot{RequestsStarted: 1, RequestsSucceeded: 1, InputTokens: 42, EstimatedUSD: 0.000002}) {
|
|
76
|
+
t.Errorf("usage = %+v", got)
|
|
77
|
+
}
|
|
78
|
+
spend := client.GetSpend()
|
|
79
|
+
if spend.Session.RequestsStarted != 1 || spend.Today.InputTokens != 42 || spend.Blocked != nil {
|
|
80
|
+
t.Errorf("spend = %+v", spend)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
tw(t, "client", "invalid questions are rejected before network submission without echoing state", func(t *testing.T) {
|
|
84
|
+
isolate(t)
|
|
85
|
+
var calls atomic.Int32
|
|
86
|
+
client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) {
|
|
87
|
+
calls.Add(1)
|
|
88
|
+
return nil, errors.New(secret)
|
|
89
|
+
}))
|
|
90
|
+
invalid := []any{
|
|
91
|
+
`{"state":"` + secret + `","questions":{}}`,
|
|
92
|
+
`{"state":"` + secret + `","questions":{"q":{"type":"score","criteria":["one"]}}}`,
|
|
93
|
+
`{"state":"` + secret + `","questions":{"q":{"type":"choice","criteria":{}}}}`,
|
|
94
|
+
`{"state":"` + secret + `","questions":{"q":{"type":"unsupported"}}}`,
|
|
95
|
+
`{"state":"` + secret + `","questions":{"q":{"type":"noul","instructions":"yes"}},"apiKey":"` + secret + `"}`,
|
|
96
|
+
}
|
|
97
|
+
var tooMany []string
|
|
98
|
+
for i := 0; i < 33; i++ {
|
|
99
|
+
tooMany = append(tooMany, fmt.Sprintf(`"q%d":{"type":"noul","instructions":"yes"}`, i))
|
|
100
|
+
}
|
|
101
|
+
invalid = append(invalid, `{"state":"`+secret+`","questions":{`+strings.Join(tooMany, ",")+`}}`)
|
|
102
|
+
for _, text := range invalid {
|
|
103
|
+
_, err := client.EvaluateRaw(context.Background(), json.RawMessage(text.(string)))
|
|
104
|
+
if !hasCode(err, CodeValidation) || strings.Contains(err.Error(), secret) {
|
|
105
|
+
t.Errorf("%.60s: %v", text, err)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if calls.Load() != 0 || client.GetUsage().RequestsStarted != 0 {
|
|
109
|
+
t.Fatalf("calls=%d usage=%+v", calls.Load(), client.GetUsage())
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
tw(t, "client", "UTF-8 byte limit applies before submission", func(t *testing.T) {
|
|
113
|
+
isolate(t)
|
|
114
|
+
client := testClient(t, Options{MaxInputBytes: 180}, doerFunc(func(*http.Request) (*http.Response, error) { t.Error("must not run"); return nil, nil }))
|
|
115
|
+
req := sampleRequest()
|
|
116
|
+
req.State = typesafe.Text(strings.Repeat("🙂", 50))
|
|
117
|
+
if _, err := client.Evaluate(context.Background(), req); !hasCode(err, CodeValidation) {
|
|
118
|
+
t.Fatalf("err = %v", err)
|
|
119
|
+
}
|
|
120
|
+
if client.GetUsage().RequestsStarted != 0 {
|
|
121
|
+
t.Fatal("a refused request must not count")
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
tw(t, "client", "request budget is shared across concurrent calls and failures", func(t *testing.T) {
|
|
125
|
+
isolate(t)
|
|
126
|
+
var calls atomic.Int32
|
|
127
|
+
client := testClient(t, Options{MaxRequests: 2, Ledger: OpenUsageLedger(LedgerOptions{Path: t.TempDir() + "/u.json"})}, failing(500, nil, &calls))
|
|
128
|
+
var wg sync.WaitGroup
|
|
129
|
+
errs := make([]error, 3)
|
|
130
|
+
for i := range errs {
|
|
131
|
+
wg.Add(1)
|
|
132
|
+
go func() { defer wg.Done(); _, errs[i] = client.Evaluate(context.Background(), sampleRequest()) }()
|
|
133
|
+
}
|
|
134
|
+
wg.Wait()
|
|
135
|
+
for i, err := range errs {
|
|
136
|
+
if err == nil {
|
|
137
|
+
t.Errorf("call %d succeeded", i)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if calls.Load() != 2 {
|
|
141
|
+
t.Errorf("calls = %d", calls.Load())
|
|
142
|
+
}
|
|
143
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeBudget) {
|
|
144
|
+
t.Errorf("err = %v", err)
|
|
145
|
+
}
|
|
146
|
+
if u := client.GetUsage(); u.RequestsStarted != 2 || u.RequestsSucceeded != 0 {
|
|
147
|
+
t.Errorf("usage = %+v", u)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
tw(t, "client", "HTTP errors are classified, never retried, and do not expose response secrets", func(t *testing.T) {
|
|
151
|
+
for _, status := range []int{400, 401, 403, 422, 429, 500, 503} {
|
|
152
|
+
isolate(t)
|
|
153
|
+
var calls atomic.Int32
|
|
154
|
+
client := testClient(t, Options{}, failing(status, nil, &calls))
|
|
155
|
+
_, err := client.Evaluate(context.Background(), sampleRequest())
|
|
156
|
+
ie, ok := err.(*IntegrationError)
|
|
157
|
+
if !ok || ie.Code != CodeHTTP || ie.Status != status || strings.Contains(ie.Message, "never-print-me") {
|
|
158
|
+
t.Errorf("%d: %v", status, err)
|
|
159
|
+
}
|
|
160
|
+
if data, _ := json.Marshal(err); strings.Contains(string(data), "never-print-me") {
|
|
161
|
+
t.Errorf("%d: secret in JSON", status)
|
|
162
|
+
}
|
|
163
|
+
if calls.Load() != 1 {
|
|
164
|
+
t.Errorf("%d: %d calls", status, calls.Load())
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
tw(t, "client", "HTTP advice names the backend's key, covers 402, and quotes a numeric Retry-After", func(t *testing.T) {
|
|
169
|
+
cases := []struct {
|
|
170
|
+
backend string
|
|
171
|
+
status int
|
|
172
|
+
retryAfter bool
|
|
173
|
+
usable bool
|
|
174
|
+
message string
|
|
175
|
+
}{
|
|
176
|
+
{"typesafe", 401, true, false, "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made."},
|
|
177
|
+
{"openrouter", 401, true, false, "TypeSafe returned HTTP 401. Check OPENROUTER_API_KEY. No automatic retry was made."},
|
|
178
|
+
{"typesafe", 402, true, true, "TypeSafe returned HTTP 402. Check your account balance. No automatic retry was made."},
|
|
179
|
+
{"openrouter", 402, true, true, "TypeSafe returned HTTP 402. Insufficient credits. Add credits at https://openrouter.ai/credits. No automatic retry was made."},
|
|
180
|
+
{"typesafe", 429, true, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. Retry after 7 seconds. No automatic retry was made."},
|
|
181
|
+
{"openrouter", 429, true, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. Retry after 7 seconds. No automatic retry was made."},
|
|
182
|
+
{"typesafe", 429, false, true, "TypeSafe returned HTTP 429. Check your account quota and try again later. No automatic retry was made."},
|
|
183
|
+
}
|
|
184
|
+
for _, c := range cases {
|
|
185
|
+
isolate(t)
|
|
186
|
+
// authState({ backend }).usable needs a key present for each backend; the client itself takes its key below.
|
|
187
|
+
t.Setenv("TYPESAFE_API_KEY", "offline-env-key-0123456789abcdef")
|
|
188
|
+
t.Setenv("OPENROUTER_API_KEY", "offline-or-key-0123456789abcdef")
|
|
189
|
+
header := http.Header{}
|
|
190
|
+
if c.retryAfter {
|
|
191
|
+
header.Set("Retry-After", "7")
|
|
192
|
+
}
|
|
193
|
+
client, err := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: failing(c.status, header, nil)})
|
|
194
|
+
if err != nil {
|
|
195
|
+
t.Fatal(err)
|
|
196
|
+
}
|
|
197
|
+
_, err = client.Evaluate(context.Background(), sampleRequest())
|
|
198
|
+
ie, ok := err.(*IntegrationError)
|
|
199
|
+
if !ok || ie.Code != CodeHTTP || ie.Status != c.status || ie.Message != c.message || strings.Contains(ie.Message, "never-print-me") {
|
|
200
|
+
t.Errorf("%s %d: %v", c.backend, c.status, err)
|
|
201
|
+
}
|
|
202
|
+
// A 401 rejects the key; a 402 is billing, so it must leave it usable (rejected statuses stay 401 and 403).
|
|
203
|
+
if s := mustAuth(t, c.backend); s.Usable != c.usable {
|
|
204
|
+
t.Errorf("%s %d: usable = %v", c.backend, c.status, s.Usable)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
tw(t, "client", "safeError keeps its one-argument form and defaults the key advice", func(t *testing.T) {
|
|
209
|
+
own := &IntegrationError{Code: CodeHTTP, Message: "kept", Status: 401}
|
|
210
|
+
if SafeError(own, nil) != own {
|
|
211
|
+
t.Error("an IntegrationError must pass through")
|
|
212
|
+
}
|
|
213
|
+
if got := SafeError(apiError(401), nil).Message; got != "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made." {
|
|
214
|
+
t.Errorf("message = %s", got)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
tw(t, "client", "cancellation before submission does not consume an attempt", func(t *testing.T) {
|
|
218
|
+
isolate(t)
|
|
219
|
+
client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) { t.Error("must not run"); return nil, nil }))
|
|
220
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
221
|
+
cancel()
|
|
222
|
+
if _, err := client.Evaluate(ctx, sampleRequest()); !hasCode(err, CodeAborted) {
|
|
223
|
+
t.Fatalf("err = %v", err)
|
|
224
|
+
}
|
|
225
|
+
if client.GetUsage().RequestsStarted != 0 {
|
|
226
|
+
t.Fatal("a cancelled request must not count")
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
tw(t, "client", "in-flight cancellation and timeout reach the transport", func(t *testing.T) {
|
|
230
|
+
isolate(t)
|
|
231
|
+
pending := doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
232
|
+
<-r.Context().Done()
|
|
233
|
+
return nil, r.Context().Err()
|
|
234
|
+
})
|
|
235
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
236
|
+
client := testClient(t, Options{Timeout: time.Second}, pending)
|
|
237
|
+
done := make(chan error, 1)
|
|
238
|
+
go func() { _, err := client.Evaluate(ctx, sampleRequest()); done <- err }()
|
|
239
|
+
time.Sleep(20 * time.Millisecond)
|
|
240
|
+
cancel()
|
|
241
|
+
if err := <-done; !hasCode(err, CodeAborted) {
|
|
242
|
+
t.Fatalf("cancel: %v", err)
|
|
243
|
+
}
|
|
244
|
+
timed := testClient(t, Options{Timeout: 10 * time.Millisecond}, pending)
|
|
245
|
+
if _, err := timed.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeTimeout) {
|
|
246
|
+
t.Fatalf("timeout: %v", err)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
tw(t, "client", "malformed successful responses fail safely", func(t *testing.T) {
|
|
250
|
+
bodies := []*http.Response{
|
|
251
|
+
jsonResponse(200, map[string]any{"secret": "private"}, nil),
|
|
252
|
+
rawResponse(200, "not-json"),
|
|
253
|
+
jsonResponse(200, map[string]any{"model": "test", "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}, "answers": map[string]any{"yes": map[string]any{"type": "noul", "noul": 5}}}, nil),
|
|
254
|
+
}
|
|
255
|
+
for i, response := range bodies {
|
|
256
|
+
isolate(t)
|
|
257
|
+
client := testClient(t, Options{}, doerFunc(func(*http.Request) (*http.Response, error) { return response, nil }))
|
|
258
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
|
|
259
|
+
t.Errorf("case %d: %v", i, err)
|
|
260
|
+
}
|
|
261
|
+
if client.GetUsage().RequestsSucceeded != 0 {
|
|
262
|
+
t.Errorf("case %d counted a success", i)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
tw(t, "client", "client ignores SDK endpoint and logging environment overrides", func(t *testing.T) {
|
|
267
|
+
isolate(t)
|
|
268
|
+
t.Setenv("TYPESAFE_BASE_URL", "https://untrusted.invalid")
|
|
269
|
+
t.Setenv("TYPESAFE_LOG_LEVEL", "debug")
|
|
270
|
+
client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
271
|
+
if !strings.HasPrefix(r.URL.String(), "https://api.typesafe.ai/") {
|
|
272
|
+
t.Errorf("url = %s", r.URL)
|
|
273
|
+
}
|
|
274
|
+
return responseFor(requestBody(t, r)), nil
|
|
275
|
+
}))
|
|
276
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
|
|
277
|
+
t.Fatal(err)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
tw(t, "client", "configuration errors are early and usage snapshots are detached", func(t *testing.T) {
|
|
281
|
+
isolate(t)
|
|
282
|
+
if _, err := New(Options{APIKey: " "}); !hasCode(err, CodeConfiguration) {
|
|
283
|
+
t.Errorf("blank key: %v", err)
|
|
284
|
+
}
|
|
285
|
+
// Go zero values mean "default", so the original's timeoutMs: 0, NaN and model: "" have no counterpart; negatives and an overlong model remain.
|
|
286
|
+
for name, opts := range map[string]Options{"timeout": {Timeout: -1}, "maxRequests": {MaxRequests: -1}, "maxInputBytes": {MaxInputBytes: -5}, "model": {Model: strings.Repeat("x", 101)}} {
|
|
287
|
+
opts.APIKey = "test-key"
|
|
288
|
+
if _, err := New(opts); !hasCode(err, CodeConfiguration) {
|
|
289
|
+
t.Errorf("%s: %v", name, err)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) { return responseFor(requestBody(t, r)), nil }))
|
|
293
|
+
snapshot := client.GetUsage()
|
|
294
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
|
|
295
|
+
t.Fatal(err)
|
|
296
|
+
}
|
|
297
|
+
if snapshot.RequestsStarted != 0 || client.GetUsage().RequestsStarted != 1 {
|
|
298
|
+
t.Fatal("snapshots must be detached")
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
tw(t, "client", "request data is snapshotted before asynchronous work", func(t *testing.T) {
|
|
302
|
+
isolate(t)
|
|
303
|
+
var sent atomic.Value
|
|
304
|
+
release, entered := make(chan struct{}), make(chan struct{})
|
|
305
|
+
client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
306
|
+
close(entered)
|
|
307
|
+
<-release
|
|
308
|
+
body := requestBody(t, r)
|
|
309
|
+
sent.Store(string(body))
|
|
310
|
+
return responseFor(body), nil
|
|
311
|
+
}))
|
|
312
|
+
req := sampleRequest()
|
|
313
|
+
done := make(chan error, 1)
|
|
314
|
+
go func() { _, err := client.Evaluate(context.Background(), req); done <- err }()
|
|
315
|
+
<-entered // the request is in flight, so it has been snapshotted
|
|
316
|
+
req.State = typesafe.Text("changed")
|
|
317
|
+
req.Questions[0] = typesafe.Ask("other", typesafe.Noul("changed?"))
|
|
318
|
+
close(release)
|
|
319
|
+
if err := <-done; err != nil {
|
|
320
|
+
t.Fatal(err)
|
|
321
|
+
}
|
|
322
|
+
if s, _ := sent.Load().(string); !strings.Contains(s, `"state":"synthetic"`) || strings.Contains(s, "changed") {
|
|
323
|
+
t.Fatalf("sent = %s", s)
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
tw(t, "client", "evaluate admits the same near-miss aliases as the agent tool", func(t *testing.T) {
|
|
327
|
+
isolate(t)
|
|
328
|
+
var sent string
|
|
329
|
+
client := testClient(t, Options{}, doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
330
|
+
body := requestBody(t, r)
|
|
331
|
+
sent = string(body)
|
|
332
|
+
return responseFor(body), nil
|
|
333
|
+
}))
|
|
334
|
+
near := map[string]any{"state": "synthetic", "questions": map[string]any{"yes": map[string]any{"type": "noul", "instructions": "Is this synthetic?", "criteria": "Is this synthetic data?"}}}
|
|
335
|
+
result, err := client.EvaluateRaw(context.Background(), near)
|
|
336
|
+
if err != nil {
|
|
337
|
+
t.Fatal(err)
|
|
338
|
+
}
|
|
339
|
+
yes, _ := result.Noul("yes")
|
|
340
|
+
if yes.Noul != 0.9 || !strings.Contains(sent, `"criteria":{"true":"Is this synthetic data?"}`) {
|
|
341
|
+
t.Fatalf("yes=%+v sent=%s", yes, sent)
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
tw(t, "client", "unknown backend throws configuration error", func(t *testing.T) {
|
|
345
|
+
_, err := New(Options{APIKey: "test-key", Backend: "bogus"})
|
|
346
|
+
if !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "Unknown judgment backend") || !strings.Contains(err.Error(), "bogus") {
|
|
347
|
+
t.Fatalf("err = %v", err)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
tw(t, "client", "known backend is called at its full request URL", func(t *testing.T) {
|
|
351
|
+
// A host does not identify the endpoint: openrouter.ai answers the SDK's own /v1/systemone with an HTML page and status 200.
|
|
352
|
+
for backend, want := range map[string]string{"typesafe": "https://api.typesafe.ai/v1/systemone", "openrouter": "https://openrouter.ai/api/alpha/decisions"} {
|
|
353
|
+
isolate(t)
|
|
354
|
+
var got string
|
|
355
|
+
client, err := New(Options{APIKey: "test-key", Backend: backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
356
|
+
got = r.URL.String()
|
|
357
|
+
return responseFor(requestBody(t, r)), nil
|
|
358
|
+
})})
|
|
359
|
+
if err != nil {
|
|
360
|
+
t.Fatal(err)
|
|
361
|
+
}
|
|
362
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || got != want {
|
|
363
|
+
t.Errorf("%s: %v url=%s", backend, err, got)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
tw(t, "client", "listModels asks each backend for its own model list", func(t *testing.T) {
|
|
368
|
+
cases := []struct {
|
|
369
|
+
backend, url string
|
|
370
|
+
wire any
|
|
371
|
+
want string
|
|
372
|
+
}{
|
|
373
|
+
{"typesafe", "https://api.typesafe.ai/v1/models", map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, "jev-latest"},
|
|
374
|
+
{"openrouter", "https://openrouter.ai/api/v1/models", map[string]any{"data": []any{map[string]any{"id": "vendor/model", "name": "Vendor: Model"}}}, "vendor/model"},
|
|
375
|
+
}
|
|
376
|
+
for _, c := range cases {
|
|
377
|
+
isolate(t)
|
|
378
|
+
var got string
|
|
379
|
+
client, _ := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
380
|
+
got = r.URL.String()
|
|
381
|
+
return jsonResponse(200, c.wire, nil), nil
|
|
382
|
+
})})
|
|
383
|
+
names, err := client.ListModels(context.Background())
|
|
384
|
+
if err != nil || len(names) != 1 || names[0] != c.want || got != c.url {
|
|
385
|
+
t.Errorf("%s: %v %v url=%s", c.backend, names, err, got)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
tw(t, "client", "a model list without the backend's declared field keeps the SDK's own shape error", func(t *testing.T) {
|
|
390
|
+
isolate(t)
|
|
391
|
+
client, _ := New(Options{APIKey: "test-key", Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
392
|
+
return jsonResponse(200, map[string]any{"items": []any{map[string]any{"name": "not the declared field"}}}, nil), nil
|
|
393
|
+
})})
|
|
394
|
+
if _, err := client.ListModels(context.Background()); !hasCode(err, CodeResponse) {
|
|
395
|
+
t.Fatalf("err = %v", err)
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
tw(t, "client", "the model list is renamed only for the backend that declares another field", func(t *testing.T) {
|
|
399
|
+
isolate(t)
|
|
400
|
+
client, _ := New(Options{APIKey: "test-key", Backend: "typesafe", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
401
|
+
return jsonResponse(200, map[string]any{"data": []any{map[string]any{"name": "not the SDK's field"}}}, nil), nil
|
|
402
|
+
})})
|
|
403
|
+
if _, err := client.ListModels(context.Background()); !hasCode(err, CodeResponse) {
|
|
404
|
+
t.Fatalf("err = %v", err)
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
tw(t, "client", "a public model list leaves the auth state unverified", func(t *testing.T) {
|
|
408
|
+
isolate(t)
|
|
409
|
+
ClearAuthState()
|
|
410
|
+
client, _ := New(Options{APIKey: "garbage-key", Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
411
|
+
return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "vendor/model", "name": "Vendor: Model"}}}, nil), nil
|
|
412
|
+
})})
|
|
413
|
+
names, err := client.ListModels(context.Background())
|
|
414
|
+
// openrouter.ai serves this list to anyone, so a success says nothing about the key.
|
|
415
|
+
if err != nil || len(names) != 1 || names[0] != "vendor/model" || mustAuth(t, "openrouter").Verified {
|
|
416
|
+
t.Fatalf("names=%v err=%v", names, err)
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
tw(t, "client", "the model in the request body is the backend's own id form", func(t *testing.T) {
|
|
420
|
+
cases := []struct{ backend, requested, want string }{
|
|
421
|
+
{"openrouter", "", "typesafe/jev-1.13"}, {"openrouter", "jev-latest", "~typesafe/jev-latest"}, {"openrouter", "jev-1.13", "typesafe/jev-1.13"},
|
|
422
|
+
{"openrouter", "jev-1.13.0", "typesafe/jev-1.13"}, {"openrouter", "vendor/other", "vendor/other"}, {"typesafe", "jev-latest", "jev-latest"},
|
|
423
|
+
}
|
|
424
|
+
for _, c := range cases {
|
|
425
|
+
isolate(t)
|
|
426
|
+
var url, model string
|
|
427
|
+
client, err := New(Options{APIKey: "test-key", Backend: c.backend, Model: c.requested, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
428
|
+
url = r.URL.String()
|
|
429
|
+
body := requestBody(t, r)
|
|
430
|
+
var sent struct{ Model string }
|
|
431
|
+
_ = json.Unmarshal(body, &sent)
|
|
432
|
+
model = sent.Model
|
|
433
|
+
return responseFor(body), nil
|
|
434
|
+
})})
|
|
435
|
+
if err != nil {
|
|
436
|
+
t.Fatal(err)
|
|
437
|
+
}
|
|
438
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || model != c.want {
|
|
439
|
+
t.Errorf("%+v: model=%s err=%v", c, model, err)
|
|
440
|
+
}
|
|
441
|
+
// OpenRouter judgments travel to its own decisions path, so the mapped id is proven on the wire that uses it.
|
|
442
|
+
wantURL := map[bool]string{true: "https://openrouter.ai/api/alpha/decisions", false: "https://api.typesafe.ai/v1/systemone"}[c.backend == "openrouter"]
|
|
443
|
+
if url != wantURL {
|
|
444
|
+
t.Errorf("%+v: url=%s", c, url)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
tw(t, "client", "a per-request model gets the same mapping as the client default", func(t *testing.T) {
|
|
449
|
+
cases := []struct{ backend, requested, want string }{
|
|
450
|
+
{"openrouter", "jev-latest", "~typesafe/jev-latest"}, {"openrouter", "jev-1.13", "typesafe/jev-1.13"}, {"openrouter", "vendor/other", "vendor/other"}, {"typesafe", "jev-latest", "jev-latest"},
|
|
451
|
+
}
|
|
452
|
+
for _, c := range cases {
|
|
453
|
+
isolate(t)
|
|
454
|
+
var model string
|
|
455
|
+
client, _ := New(Options{APIKey: "test-key", Backend: c.backend, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
456
|
+
body := requestBody(t, r)
|
|
457
|
+
var sent struct{ Model string }
|
|
458
|
+
_ = json.Unmarshal(body, &sent)
|
|
459
|
+
model = sent.Model
|
|
460
|
+
return responseFor(body), nil
|
|
461
|
+
})})
|
|
462
|
+
req := sampleRequest()
|
|
463
|
+
req.Model = c.requested
|
|
464
|
+
if _, err := client.Evaluate(context.Background(), req); err != nil || model != c.want {
|
|
465
|
+
t.Errorf("%+v: model=%s err=%v", c, model, err)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
tw(t, "client", "a TypeSafe key is never sent to another backend", func(t *testing.T) {
|
|
470
|
+
isolate(t)
|
|
471
|
+
t.Setenv("TYPESAFE_API_KEY", "ts-test-key-1234567890123456")
|
|
472
|
+
_, err := New(Options{Backend: "openrouter"})
|
|
473
|
+
if !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "OPENROUTER_API_KEY") || strings.Contains(err.Error(), "typesafe login") {
|
|
474
|
+
t.Fatalf("err = %v", err)
|
|
475
|
+
}
|
|
476
|
+
})
|
|
477
|
+
tw(t, "client", "key resolution picks the right env var per backend", func(t *testing.T) {
|
|
478
|
+
isolate(t)
|
|
479
|
+
// With no key set, the openrouter backend should complain about OPENROUTER_API_KEY.
|
|
480
|
+
if _, err := New(Options{Backend: "openrouter"}); !hasCode(err, CodeConfiguration) || !strings.Contains(err.Error(), "OPENROUTER_API_KEY") {
|
|
481
|
+
t.Fatalf("err = %v", err)
|
|
482
|
+
}
|
|
483
|
+
// Setting the env var for the right backend gets past key resolution; construction sends nothing.
|
|
484
|
+
t.Setenv("OPENROUTER_API_KEY", "or-test-key-1234567890123456")
|
|
485
|
+
called := false
|
|
486
|
+
if _, err := New(Options{Backend: "openrouter", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { called = true; return nil, nil })}); err != nil || called {
|
|
487
|
+
t.Fatalf("err = %v called=%v", err, called)
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
}
|