@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/evaluation.go
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"sort"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// MarshalJSON encodes the evaluation the way the original's JSON.stringify(result) does: model, answers
|
|
9
|
+
// (in the order the questions were asked), usage, then elapsedMs.
|
|
10
|
+
func (e *Evaluation) MarshalJSON() ([]byte, error) {
|
|
11
|
+
raw, err := json.Marshal(e.SystemOneResult)
|
|
12
|
+
if err != nil {
|
|
13
|
+
return nil, err
|
|
14
|
+
}
|
|
15
|
+
tree, err := ParseJSON(raw)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return nil, err
|
|
18
|
+
}
|
|
19
|
+
source, ok := tree.(*Object)
|
|
20
|
+
if !ok {
|
|
21
|
+
return nil, newError(CodeResponse, "TypeSafe returned an unreadable or unexpected response.")
|
|
22
|
+
}
|
|
23
|
+
out := NewObject()
|
|
24
|
+
if v, ok := source.Get("model"); ok {
|
|
25
|
+
out.Set("model", v)
|
|
26
|
+
}
|
|
27
|
+
if v, ok := source.Get("answers"); ok {
|
|
28
|
+
out.Set("answers", e.orderAnswers(v))
|
|
29
|
+
}
|
|
30
|
+
if v, ok := source.Get("usage"); ok {
|
|
31
|
+
out.Set("usage", v)
|
|
32
|
+
}
|
|
33
|
+
for _, k := range source.Keys() {
|
|
34
|
+
if !out.Has(k) {
|
|
35
|
+
out.Set(k, source.vals[k])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out.Set("elapsedMs", float64(e.ElapsedMs))
|
|
39
|
+
return EncodeJSON(out)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// orderAnswers reorders an answers object to the question order; unknown ids follow, sorted.
|
|
43
|
+
func (e *Evaluation) orderAnswers(v any) any {
|
|
44
|
+
answers, ok := v.(*Object)
|
|
45
|
+
if !ok {
|
|
46
|
+
return v
|
|
47
|
+
}
|
|
48
|
+
ordered := NewObject()
|
|
49
|
+
for _, id := range e.Order {
|
|
50
|
+
if a, ok := answers.Get(id); ok {
|
|
51
|
+
ordered.Set(id, a)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
rest := []string{}
|
|
55
|
+
for _, id := range answers.Keys() {
|
|
56
|
+
if !ordered.Has(id) {
|
|
57
|
+
rest = append(rest, id)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
sort.Strings(rest)
|
|
61
|
+
for _, id := range rest {
|
|
62
|
+
a, _ := answers.Get(id)
|
|
63
|
+
ordered.Set(id, a)
|
|
64
|
+
}
|
|
65
|
+
return ordered
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// DetailsJSON is MarshalJSON plus an "order" array naming the answer ids in question order. A host that decodes
|
|
69
|
+
// tool details into maps loses key order, so a renderer reads the order from here.
|
|
70
|
+
func (e *Evaluation) DetailsJSON() ([]byte, error) {
|
|
71
|
+
raw, err := e.MarshalJSON()
|
|
72
|
+
if err != nil {
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
tree, err := ParseJSON(raw)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, err
|
|
78
|
+
}
|
|
79
|
+
obj := tree.(*Object)
|
|
80
|
+
order := make([]any, len(e.Order))
|
|
81
|
+
for i, id := range e.Order {
|
|
82
|
+
order[i] = id
|
|
83
|
+
}
|
|
84
|
+
obj.Set("order", order)
|
|
85
|
+
return EncodeJSON(obj)
|
|
86
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"required": [
|
|
4
|
+
"state",
|
|
5
|
+
"questions"
|
|
6
|
+
],
|
|
7
|
+
"properties": {
|
|
8
|
+
"state": {
|
|
9
|
+
"anyOf": [
|
|
10
|
+
{
|
|
11
|
+
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "null"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "array",
|
|
18
|
+
"items": {}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "object",
|
|
22
|
+
"patternProperties": {
|
|
23
|
+
"^.*$": {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"description": "What to judge: text, or an object whose fields the questions name."
|
|
28
|
+
},
|
|
29
|
+
"questions": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"patternProperties": {
|
|
32
|
+
"^.*$": {
|
|
33
|
+
"anyOf": [
|
|
34
|
+
{
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": [
|
|
37
|
+
"type"
|
|
38
|
+
],
|
|
39
|
+
"properties": {
|
|
40
|
+
"type": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"const": "noul",
|
|
43
|
+
"description": "Yes or no: the probability the instructions hold."
|
|
44
|
+
},
|
|
45
|
+
"instructions": {
|
|
46
|
+
"anyOf": [
|
|
47
|
+
{
|
|
48
|
+
"type": "string"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "null"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"type": "object",
|
|
59
|
+
"patternProperties": {
|
|
60
|
+
"^.*$": {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"description": "One judgment about the whole state, phrased as a question or a statement."
|
|
65
|
+
},
|
|
66
|
+
"criteria": {
|
|
67
|
+
"anyOf": [
|
|
68
|
+
{
|
|
69
|
+
"type": "null"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"true": {
|
|
75
|
+
"anyOf": [
|
|
76
|
+
{
|
|
77
|
+
"type": "string"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"type": "null"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"type": "object",
|
|
88
|
+
"patternProperties": {
|
|
89
|
+
"^.*$": {}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
"false": {
|
|
95
|
+
"anyOf": [
|
|
96
|
+
{
|
|
97
|
+
"type": "string"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"type": "null"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "array",
|
|
104
|
+
"items": {}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"type": "object",
|
|
108
|
+
"patternProperties": {
|
|
109
|
+
"^.*$": {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"additionalProperties": false
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
"description": "Optional: what counts as yes and what counts as no, { true, false }."
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"additionalProperties": false
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"type": "object",
|
|
125
|
+
"required": [
|
|
126
|
+
"type",
|
|
127
|
+
"criteria"
|
|
128
|
+
],
|
|
129
|
+
"properties": {
|
|
130
|
+
"type": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"const": "choice",
|
|
133
|
+
"description": "Pick one criteria label."
|
|
134
|
+
},
|
|
135
|
+
"instructions": {
|
|
136
|
+
"anyOf": [
|
|
137
|
+
{
|
|
138
|
+
"type": "string"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "null"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"type": "array",
|
|
145
|
+
"items": {}
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"type": "object",
|
|
149
|
+
"patternProperties": {
|
|
150
|
+
"^.*$": {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"description": "One judgment about the whole state, phrased as a question or a statement."
|
|
155
|
+
},
|
|
156
|
+
"criteria": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"patternProperties": {
|
|
159
|
+
"^.*$": {
|
|
160
|
+
"anyOf": [
|
|
161
|
+
{
|
|
162
|
+
"type": "string"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"type": "null"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"type": "array",
|
|
169
|
+
"items": {}
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"type": "object",
|
|
173
|
+
"patternProperties": {
|
|
174
|
+
"^.*$": {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"minProperties": 1,
|
|
181
|
+
"maxProperties": 64,
|
|
182
|
+
"description": "The options as a map from label to when it applies: { billing: \"Charges and payments\", other: null }. 1–64 entries."
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
"additionalProperties": false
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"type": "object",
|
|
189
|
+
"required": [
|
|
190
|
+
"type",
|
|
191
|
+
"criteria"
|
|
192
|
+
],
|
|
193
|
+
"properties": {
|
|
194
|
+
"type": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"const": "score",
|
|
197
|
+
"description": "Rate against the ordered criteria levels."
|
|
198
|
+
},
|
|
199
|
+
"instructions": {
|
|
200
|
+
"anyOf": [
|
|
201
|
+
{
|
|
202
|
+
"type": "string"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"type": "null"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"type": "array",
|
|
209
|
+
"items": {}
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"type": "object",
|
|
213
|
+
"patternProperties": {
|
|
214
|
+
"^.*$": {}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
"description": "One judgment about the whole state, phrased as a question or a statement."
|
|
219
|
+
},
|
|
220
|
+
"criteria": {
|
|
221
|
+
"type": "array",
|
|
222
|
+
"items": {
|
|
223
|
+
"anyOf": [
|
|
224
|
+
{
|
|
225
|
+
"type": "string"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"type": "null"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"type": "array",
|
|
232
|
+
"items": {}
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"type": "object",
|
|
236
|
+
"patternProperties": {
|
|
237
|
+
"^.*$": {}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
"minItems": 2,
|
|
243
|
+
"maxItems": 32,
|
|
244
|
+
"description": "Ordered rubric levels, lowest first: [\"neutral\", \"angry\"]. 2–32 levels."
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
"additionalProperties": false
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
"minProperties": 1,
|
|
253
|
+
"maxProperties": 32,
|
|
254
|
+
"description": "Questions keyed by a short id, as an object map, not an array: { \"urgent\": { type: \"noul\", instructions: ... } }."
|
|
255
|
+
},
|
|
256
|
+
"model": {
|
|
257
|
+
"type": "string",
|
|
258
|
+
"minLength": 1,
|
|
259
|
+
"maxLength": 100,
|
|
260
|
+
"description": "Jev model id, e.g. jev-latest. Omit for the default."
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
"additionalProperties": false
|
|
264
|
+
}
|
package/gaps_test.go
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"runtime"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Cases the mutation check showed unproven by the ported twins.
|
|
14
|
+
|
|
15
|
+
func TestGroupReadableKeyFileIsRefused(t *testing.T) {
|
|
16
|
+
if runtime.GOOS == "windows" {
|
|
17
|
+
t.Skip("POSIX permission bits")
|
|
18
|
+
}
|
|
19
|
+
isolate(t)
|
|
20
|
+
_, _ = StoreAPIKey(validKey)
|
|
21
|
+
_ = os.Chmod(CredentialsPath(), 0o640)
|
|
22
|
+
if s, _ := KeySituationFor(nil); s.Kind != KeyUnusable {
|
|
23
|
+
t.Fatalf("a group-readable key file must be unusable: %+v", s)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestAskDefaultDeadlineIsFifteenSeconds(t *testing.T) {
|
|
28
|
+
var remaining time.Duration
|
|
29
|
+
judge := judgeFunc(func(ctx context.Context, _ typesafe.SystemOneRequest) (*Evaluation, error) {
|
|
30
|
+
deadline, ok := ctx.Deadline()
|
|
31
|
+
if !ok {
|
|
32
|
+
t.Error("Ask must set a deadline")
|
|
33
|
+
}
|
|
34
|
+
remaining = time.Until(deadline)
|
|
35
|
+
return &Evaluation{SystemOneResult: &typesafe.SystemOneResult{}}, nil
|
|
36
|
+
})
|
|
37
|
+
Ask(context.Background(), judge, sampleRequest(), AskOptions{})
|
|
38
|
+
if remaining < 14*time.Second || remaining > 15*time.Second {
|
|
39
|
+
t.Fatalf("deadline in %v", remaining)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func TestAbortedFailureStopsTheBatchToo(t *testing.T) {
|
|
44
|
+
calls := 0
|
|
45
|
+
judge := batchJudge(func(ctx context.Context, _ typesafe.SystemOneRequest) (*Evaluation, error) {
|
|
46
|
+
calls++
|
|
47
|
+
return nil, newError(CodeAborted, "cancelled")
|
|
48
|
+
})
|
|
49
|
+
batch := EvaluateMany(context.Background(), judge, []typesafe.SystemOneRequest{sampleRequest(), sampleRequest(), sampleRequest()}, BatchOptions{Concurrency: 1})
|
|
50
|
+
if calls != 1 || batch.Skipped != 2 {
|
|
51
|
+
t.Fatalf("calls=%d skipped=%d", calls, batch.Skipped)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type batchJudge func(context.Context, typesafe.SystemOneRequest) (*Evaluation, error)
|
|
56
|
+
|
|
57
|
+
func (f batchJudge) Evaluate(ctx context.Context, r typesafe.SystemOneRequest) (*Evaluation, error) {
|
|
58
|
+
return f(ctx, r)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func TestOnlyHTTPRejectionsDegradeTheKey(t *testing.T) {
|
|
62
|
+
isolate(t)
|
|
63
|
+
t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789abcdef")
|
|
64
|
+
// A 401 status on a non-HTTP failure code is not a rejection.
|
|
65
|
+
RecordAuthFailure(&IntegrationError{Code: CodeResponse, Message: "odd", Status: 401}, time.Now())
|
|
66
|
+
if !mustAuth(t, nil).Usable {
|
|
67
|
+
t.Fatal("only an HTTP 401 or 403 rejects the key")
|
|
68
|
+
}
|
|
69
|
+
RecordAuthFailure(&IntegrationError{Code: CodeHTTP, Message: "forbidden", Status: 403}, time.Now())
|
|
70
|
+
if mustAuth(t, nil).Usable {
|
|
71
|
+
t.Fatal("a 403 rejects the key")
|
|
72
|
+
}
|
|
73
|
+
RecordAuthFailure(&IntegrationError{Code: CodeHTTP, Message: "gone", Status: 404}, time.Now())
|
|
74
|
+
if !mustAuth(t, nil).Usable {
|
|
75
|
+
t.Fatal("a 404 does not")
|
|
76
|
+
}
|
|
77
|
+
}
|
package/go.mod
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module github.com/MichaelKinsy/pigpen/components/pi-typesafe-api
|
|
2
|
+
|
|
3
|
+
go 1.26
|
|
4
|
+
|
|
5
|
+
// PiG resolves this requirement to the version-matched staged SDK at build time.
|
|
6
|
+
require github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1
|
|
7
|
+
|
|
8
|
+
// The shared TypeSafe client (components/typesafe), found through go.work.
|
|
9
|
+
require github.com/MichaelKinsy/pigpen/components/typesafe v0.0.0
|
package/go.sum
ADDED
package/helpers_test.go
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"regexp"
|
|
12
|
+
"strings"
|
|
13
|
+
"sync"
|
|
14
|
+
"testing"
|
|
15
|
+
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// isolate points every path and every key variable at a temporary directory (rule 17): a test never
|
|
20
|
+
// reads or writes the real agent directory, and never sees a key from the environment.
|
|
21
|
+
func isolate(t *testing.T) string {
|
|
22
|
+
t.Helper()
|
|
23
|
+
dir := t.TempDir()
|
|
24
|
+
t.Setenv("HOME", dir)
|
|
25
|
+
t.Setenv("PIG_HOME", filepath.Join(dir, "pighome"))
|
|
26
|
+
t.Setenv("PIG_CODING_AGENT_DIR", filepath.Join(dir, "agent"))
|
|
27
|
+
t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(dir, "pi-agent"))
|
|
28
|
+
t.Setenv("PIG_USE_PI_DIRS", "")
|
|
29
|
+
for _, name := range []string{"TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "COMMANDCODE_API_KEY", "GATEWAY_JEV_KEY",
|
|
30
|
+
"PI_TYPESAFE_ENABLED", "PI_TYPESAFE_MAX_REQUESTS_PER_DAY", "PI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAY", "PI_TYPESAFE_MAX_USD_PER_DAY",
|
|
31
|
+
"TYPESAFE_BASE_URL", "TYPESAFE_LOG_LEVEL"} {
|
|
32
|
+
t.Setenv(name, "")
|
|
33
|
+
}
|
|
34
|
+
return filepath.Join(dir, "agent")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type doerFunc func(*http.Request) (*http.Response, error)
|
|
38
|
+
|
|
39
|
+
func (f doerFunc) Do(r *http.Request) (*http.Response, error) { return f(r) }
|
|
40
|
+
|
|
41
|
+
func jsonResponse(status int, body any, header http.Header) *http.Response {
|
|
42
|
+
data, _ := json.Marshal(body)
|
|
43
|
+
if header == nil {
|
|
44
|
+
header = http.Header{}
|
|
45
|
+
}
|
|
46
|
+
header.Set("Content-Type", "application/json")
|
|
47
|
+
return &http.Response{StatusCode: status, Status: http.StatusText(status), Header: header, Body: io.NopCloser(bytes.NewReader(data)), ContentLength: int64(len(data))}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func rawResponse(status int, body string) *http.Response {
|
|
51
|
+
return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader([]byte(body)))}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// answersFor builds the answers a server returns for questions: the first option with certainty.
|
|
55
|
+
func answersFor(questions typesafe.Questions) map[string]any {
|
|
56
|
+
answers := map[string]any{}
|
|
57
|
+
for _, nq := range questions {
|
|
58
|
+
switch q := nq.Question.(type) {
|
|
59
|
+
case typesafe.NoulQuestion:
|
|
60
|
+
answers[nq.Name] = map[string]any{"type": "noul", "noul": 0.9}
|
|
61
|
+
case typesafe.ChoiceQuestion:
|
|
62
|
+
probs := map[string]any{}
|
|
63
|
+
for i, o := range q.Criteria {
|
|
64
|
+
probs[o.Label] = map[bool]float64{true: 1, false: 0}[i == 0]
|
|
65
|
+
}
|
|
66
|
+
answers[nq.Name] = map[string]any{"type": "choice", "choice": q.Criteria[0].Label, "confidence": 1, "probabilities": probs}
|
|
67
|
+
case typesafe.ScoreQuestion:
|
|
68
|
+
probs, legend := map[string]any{}, map[string]any{}
|
|
69
|
+
for i, level := range q.Criteria {
|
|
70
|
+
probs[itoa(i)] = map[bool]float64{true: 1, false: 0}[i == 0]
|
|
71
|
+
legend[itoa(i)] = level.Data()
|
|
72
|
+
}
|
|
73
|
+
answers[nq.Name] = map[string]any{"type": "score", "score": 0, "confidence": 1, "probabilities": probs, "legend": legend}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return answers
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func itoa(i int) string { b, _ := json.Marshal(i); return string(b) }
|
|
80
|
+
|
|
81
|
+
// responseFor is the successful reply of the fake TypeSafe server to a request body.
|
|
82
|
+
func responseFor(body []byte) *http.Response {
|
|
83
|
+
var req struct {
|
|
84
|
+
Questions json.RawMessage `json:"questions"`
|
|
85
|
+
}
|
|
86
|
+
_ = json.Unmarshal(body, &req)
|
|
87
|
+
questions, err := typesafe.ParseQuestions(req.Questions)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return jsonResponse(400, map[string]any{"error": "bad questions"}, nil)
|
|
90
|
+
}
|
|
91
|
+
return jsonResponse(200, map[string]any{"model": "jev-test", "answers": answersFor(questions), "usage": map[string]any{"input_tokens": 42, "output_tokens": 0}}, nil)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func requestBody(t *testing.T, r *http.Request) []byte {
|
|
95
|
+
t.Helper()
|
|
96
|
+
if r.Body == nil {
|
|
97
|
+
return nil
|
|
98
|
+
}
|
|
99
|
+
data, err := io.ReadAll(r.Body)
|
|
100
|
+
if err != nil {
|
|
101
|
+
t.Fatal(err)
|
|
102
|
+
}
|
|
103
|
+
return data
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// answering is a fake server that answers every questions request and counts the calls.
|
|
107
|
+
type answering struct {
|
|
108
|
+
mu sync.Mutex
|
|
109
|
+
calls int
|
|
110
|
+
urls []string
|
|
111
|
+
seen []*http.Request
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (a *answering) Do(r *http.Request) (*http.Response, error) {
|
|
115
|
+
body, _ := io.ReadAll(r.Body)
|
|
116
|
+
a.mu.Lock()
|
|
117
|
+
a.calls++
|
|
118
|
+
a.urls = append(a.urls, r.URL.String())
|
|
119
|
+
a.seen = append(a.seen, r)
|
|
120
|
+
a.mu.Unlock()
|
|
121
|
+
return responseFor(body), nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func (a *answering) count() int { a.mu.Lock(); defer a.mu.Unlock(); return a.calls }
|
|
125
|
+
|
|
126
|
+
func sampleRequest() typesafe.SystemOneRequest {
|
|
127
|
+
return typesafe.SystemOneRequest{State: typesafe.Text("synthetic"), Questions: typesafe.Questions{typesafe.Ask("yes", typesafe.Noul("Is this synthetic?"))}}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func manyQuestions(n int) typesafe.Questions {
|
|
131
|
+
qs := make(typesafe.Questions, n)
|
|
132
|
+
for i := range qs {
|
|
133
|
+
qs[i] = typesafe.Ask("q"+itoa(i), typesafe.Noul("Question "+itoa(i)+"?"))
|
|
134
|
+
}
|
|
135
|
+
return qs
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func hasCode(err error, code ErrorCode) bool {
|
|
139
|
+
ie, ok := err.(*IntegrationError)
|
|
140
|
+
return ok && ie.Code == code
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func mustTree(t *testing.T, text string) any {
|
|
144
|
+
t.Helper()
|
|
145
|
+
v, err := ParseJSON([]byte(text))
|
|
146
|
+
if err != nil {
|
|
147
|
+
t.Fatal(err)
|
|
148
|
+
}
|
|
149
|
+
return v
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func writeFile(t *testing.T, path, content string, mode os.FileMode) {
|
|
153
|
+
t.Helper()
|
|
154
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
|
158
|
+
t.Fatal(err)
|
|
159
|
+
}
|
|
160
|
+
_ = os.Chmod(path, mode)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
var _ = context.Background
|
|
164
|
+
|
|
165
|
+
func regexpMatch(pattern, s string) bool { return regexp.MustCompile(pattern).MatchString(s) }
|
|
166
|
+
|
|
167
|
+
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
|
168
|
+
|
|
169
|
+
func apiError(status int) error { return typesafe.NewAPIError(status, nil, http.Header{}) }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Package hostmodel is the own-model backend on the PiG Go SDK: it answers TypeSafe's typed questions with the
|
|
2
|
+
// model PiG is configured with, chosen at call time from the request's Context, so a model switch takes effect
|
|
3
|
+
// on the next call without rebuilding the client. Nothing here names a provider or a model.
|
|
4
|
+
//
|
|
5
|
+
// Pass the Evaluator to pitypesafe.New as Options.Evaluator with Backend "ownmodel", and put the tool call's
|
|
6
|
+
// Context in the request context with [WithContext].
|
|
7
|
+
package hostmodel
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"context"
|
|
11
|
+
|
|
12
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
13
|
+
pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/pigmodel"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type contextKey struct{}
|
|
20
|
+
|
|
21
|
+
// WithContext returns a context that carries the PiG extension Context the model is read from.
|
|
22
|
+
func WithContext(ctx context.Context, host sdk.Context) context.Context {
|
|
23
|
+
return context.WithValue(ctx, contextKey{}, host)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Options configure the backend built for each call.
|
|
27
|
+
type Options struct {
|
|
28
|
+
// AnswerMode is ownmodel.Probabilities (the default) or ownmodel.Discrete.
|
|
29
|
+
AnswerMode ownmodel.AnswerMode
|
|
30
|
+
// NormalizeProbabilities rescales invalid probability distributions to sum to 1.
|
|
31
|
+
NormalizeProbabilities bool
|
|
32
|
+
// MalformedRetries is the number of corrective retries when the output fails validation.
|
|
33
|
+
MalformedRetries int
|
|
34
|
+
// Logger receives request summaries; nil discards.
|
|
35
|
+
Logger typesafe.Logger
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Evaluator answers questions with the host's active model. It is safe for concurrent use.
|
|
39
|
+
type Evaluator struct{ opts Options }
|
|
40
|
+
|
|
41
|
+
// New returns an Evaluator.
|
|
42
|
+
func New(opts Options) *Evaluator { return &Evaluator{opts: opts} }
|
|
43
|
+
|
|
44
|
+
var _ typesafe.Evaluator = (*Evaluator)(nil)
|
|
45
|
+
|
|
46
|
+
// ModelRef names the model a Context is configured with.
|
|
47
|
+
type ModelRef struct{ Provider, ID string }
|
|
48
|
+
|
|
49
|
+
// Active returns the model PiG is configured with, or an error when none is selected. The setup errors here
|
|
50
|
+
// are configuration errors (*pitypesafe.IntegrationError) so their reason reaches the operator unchanged.
|
|
51
|
+
func Active(host sdk.Context) (ModelRef, error) {
|
|
52
|
+
info, err := host.GetModelInfo()
|
|
53
|
+
if err != nil {
|
|
54
|
+
return ModelRef{}, err
|
|
55
|
+
}
|
|
56
|
+
if info == nil || info.ID == "" {
|
|
57
|
+
return ModelRef{}, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "No model is configured in PiG; select one with /model first."}
|
|
58
|
+
}
|
|
59
|
+
return ModelRef{Provider: info.Provider, ID: info.ID}, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SystemOne implements typesafe.Evaluator on the model of the Context in ctx.
|
|
63
|
+
func (e *Evaluator) SystemOne(ctx context.Context, req typesafe.SystemOneRequest, opts *typesafe.RequestOptions) (*typesafe.SystemOneResult, error) {
|
|
64
|
+
host, ok := ctx.Value(contextKey{}).(sdk.Context)
|
|
65
|
+
if !ok {
|
|
66
|
+
return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "The own-model backend needs the PiG Context in the request context (hostmodel.WithContext)."}
|
|
67
|
+
}
|
|
68
|
+
ref, err := Active(host)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, err
|
|
71
|
+
}
|
|
72
|
+
model, err := pigmodel.New(host.ModelRegistry(), pigmodel.Ref{Provider: ref.Provider, ID: ref.ID})
|
|
73
|
+
if err != nil {
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
backend, err := ownmodel.New(ownmodel.Options{
|
|
77
|
+
Model: model,
|
|
78
|
+
AnswerMode: e.opts.AnswerMode,
|
|
79
|
+
NormalizeProbabilities: e.opts.NormalizeProbabilities,
|
|
80
|
+
MalformedRetries: e.opts.MalformedRetries,
|
|
81
|
+
Logger: e.opts.Logger,
|
|
82
|
+
})
|
|
83
|
+
if err != nil {
|
|
84
|
+
return nil, err
|
|
85
|
+
}
|
|
86
|
+
return backend.SystemOne(ctx, req, opts)
|
|
87
|
+
}
|