@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,404 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strings"
|
|
7
|
+
"sync/atomic"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const (
|
|
12
|
+
ccKey = "cc_fake_key_0123456789abcdef"
|
|
13
|
+
gwKey = "gw_fake_key_0123456789abcdef"
|
|
14
|
+
tsKey = "ts_fake_key_0123456789abcdef"
|
|
15
|
+
storedKey = "st_fake_key_0123456789abcdef"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func gatewayEndpoint() BackendEndpoint {
|
|
19
|
+
return BackendEndpoint{BackendConfig: BackendConfig{Label: "Gateway", Host: "https://gw.example.com", Path: "/jev/v1/systemone", KeyEnv: "GATEWAY_JEV_KEY"}, DefaultModel: "jev-latest"}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// endpointMap is a caller-supplied endpoint as a settings file would hold it, so a value of the wrong type is expressible.
|
|
23
|
+
func endpointMap(overrides map[string]any) map[string]any {
|
|
24
|
+
m := map[string]any{"label": "Gateway", "host": "https://gw.example.com", "keyEnv": "GATEWAY_JEV_KEY", "defaultModel": "jev-latest"}
|
|
25
|
+
for k, v := range overrides {
|
|
26
|
+
if v == deleted {
|
|
27
|
+
delete(m, k)
|
|
28
|
+
} else {
|
|
29
|
+
m[k] = v
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return m
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const deleted = "\x00delete"
|
|
36
|
+
|
|
37
|
+
type refusalOptions struct{ apiKey, model string }
|
|
38
|
+
|
|
39
|
+
func assertRefuses(t *testing.T, backend any, message string, o refusalOptions) {
|
|
40
|
+
t.Helper()
|
|
41
|
+
var calls atomic.Int32
|
|
42
|
+
_, err := New(Options{Backend: backend, APIKey: o.apiKey, Model: o.model, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
43
|
+
calls.Add(1)
|
|
44
|
+
return rawResponse(200, "{}"), nil
|
|
45
|
+
})})
|
|
46
|
+
if !hasCode(err, CodeConfiguration) || err.Error() != message || strings.Contains(err.Error(), "user:pw") {
|
|
47
|
+
t.Errorf("backend %v: err = %v, want configuration %q", backend, err, message)
|
|
48
|
+
}
|
|
49
|
+
if calls.Load() != 0 {
|
|
50
|
+
t.Errorf("a refused backend sent %d requests", calls.Load())
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func TestBackends(t *testing.T) {
|
|
55
|
+
tw(t, "backends", "a commandcode request goes to the Command Code path with its own key and model", func(t *testing.T) {
|
|
56
|
+
isolate(t)
|
|
57
|
+
t.Setenv("COMMANDCODE_API_KEY", ccKey)
|
|
58
|
+
calls := 0
|
|
59
|
+
client, err := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
60
|
+
calls++
|
|
61
|
+
if r.URL.String() != "https://api.commandcode.ai/provider/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+ccKey {
|
|
62
|
+
t.Errorf("url=%s auth=%s", r.URL, r.Header.Get("Authorization"))
|
|
63
|
+
}
|
|
64
|
+
body := requestBody(t, r)
|
|
65
|
+
if !strings.Contains(string(body), `"model":"typesafe/jev"`) {
|
|
66
|
+
t.Errorf("body = %s", body)
|
|
67
|
+
}
|
|
68
|
+
return responseFor(body), nil
|
|
69
|
+
})})
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatal(err)
|
|
72
|
+
}
|
|
73
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
|
|
74
|
+
t.Fatalf("err = %v calls=%d", err, calls)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
tw(t, "backends", "a per-request model on commandcode is sent unchanged", func(t *testing.T) {
|
|
78
|
+
isolate(t)
|
|
79
|
+
t.Setenv("COMMANDCODE_API_KEY", ccKey)
|
|
80
|
+
client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
81
|
+
body := requestBody(t, r)
|
|
82
|
+
if !strings.Contains(string(body), `"model":"jev-2.0"`) {
|
|
83
|
+
t.Errorf("body = %s", body)
|
|
84
|
+
}
|
|
85
|
+
return responseFor(body), nil
|
|
86
|
+
})})
|
|
87
|
+
req := sampleRequest()
|
|
88
|
+
req.Model = "jev-2.0"
|
|
89
|
+
if _, err := client.Evaluate(context.Background(), req); err != nil {
|
|
90
|
+
t.Fatal(err)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
tw(t, "backends", "a caller-supplied endpoint sends to its own path with its own key and unmapped model", func(t *testing.T) {
|
|
94
|
+
isolate(t)
|
|
95
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
96
|
+
calls := 0
|
|
97
|
+
client, err := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
98
|
+
calls++
|
|
99
|
+
body := requestBody(t, r)
|
|
100
|
+
if r.URL.String() != "https://gw.example.com/jev/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+gwKey || !strings.Contains(string(body), `"model":"jev-latest"`) {
|
|
101
|
+
t.Errorf("url=%s auth=%s body=%s", r.URL, r.Header.Get("Authorization"), body)
|
|
102
|
+
}
|
|
103
|
+
return responseFor(body), nil
|
|
104
|
+
})})
|
|
105
|
+
if err != nil {
|
|
106
|
+
t.Fatal(err)
|
|
107
|
+
}
|
|
108
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
|
|
109
|
+
t.Fatalf("err = %v calls=%d", err, calls)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
tw(t, "backends", "an endpoint without a path uses the SDK's own /v1/systemone", func(t *testing.T) {
|
|
113
|
+
isolate(t)
|
|
114
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
115
|
+
endpoint := gatewayEndpoint()
|
|
116
|
+
endpoint.Path = ""
|
|
117
|
+
var url string
|
|
118
|
+
client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
119
|
+
url = r.URL.String()
|
|
120
|
+
return responseFor(requestBody(t, r)), nil
|
|
121
|
+
})})
|
|
122
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || url != "https://gw.example.com/v1/systemone" {
|
|
123
|
+
t.Fatalf("err = %v url=%s", err, url)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
tw(t, "backends", "an endpoint may use a loopback http: host", func(t *testing.T) {
|
|
127
|
+
isolate(t)
|
|
128
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
129
|
+
endpoint := BackendEndpoint{BackendConfig: BackendConfig{Label: "Local", Host: "http://127.0.0.1:8787", KeyEnv: "GATEWAY_JEV_KEY"}, DefaultModel: "jev-latest"}
|
|
130
|
+
var url string
|
|
131
|
+
client, err := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
132
|
+
url = r.URL.String()
|
|
133
|
+
return responseFor(requestBody(t, r)), nil
|
|
134
|
+
})})
|
|
135
|
+
if err != nil {
|
|
136
|
+
t.Fatal(err)
|
|
137
|
+
}
|
|
138
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || url != "http://127.0.0.1:8787/v1/systemone" {
|
|
139
|
+
t.Fatalf("err = %v url=%s", err, url)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
tw(t, "backends", "an endpoint label must be a nonempty string of at most 60 characters", func(t *testing.T) {
|
|
143
|
+
for _, label := range []any{7, "", " ", strings.Repeat("x", 61)} {
|
|
144
|
+
assertRefuses(t, endpointMap(map[string]any{"label": label}), "Backend label must be a nonempty string of at most 60 characters.", refusalOptions{})
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
tw(t, "backends", "an endpoint host must be an absolute https: URL with no user info, path, query, or fragment", func(t *testing.T) {
|
|
148
|
+
for _, host := range []string{"http://gw.example.com", "https://user:pw@gw.example.com", "https://gw.example.com/prefix", "https://gw.example.com?x=1", "https://gw.example.com#f", "ftp://gw.example.com", "not a url"} {
|
|
149
|
+
assertRefuses(t, endpointMap(map[string]any{"host": host}), hostMessage, refusalOptions{})
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
tw(t, "backends", `an endpoint path must be a string that starts with "/"`, func(t *testing.T) {
|
|
153
|
+
for _, path := range []any{"jev", "x?q", "x#f", 7} {
|
|
154
|
+
assertRefuses(t, endpointMap(map[string]any{"path": path}), `Backend path must be a string that starts with "/".`, refusalOptions{})
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
tw(t, "backends", `an endpoint modelsPath must be a string that starts with "/"`, func(t *testing.T) {
|
|
158
|
+
for _, p := range []any{"models", "x?q", 7} {
|
|
159
|
+
assertRefuses(t, endpointMap(map[string]any{"modelsPath": p}), `Backend modelsPath must be a string that starts with "/".`, refusalOptions{})
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
tw(t, "backends", "an endpoint modelsField must be a nonempty string", func(t *testing.T) {
|
|
163
|
+
for _, v := range []any{"", 7} {
|
|
164
|
+
assertRefuses(t, endpointMap(map[string]any{"modelsField": v}), "Backend modelsField must be a nonempty string.", refusalOptions{})
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
tw(t, "backends", "an endpoint modelsIdField must be a nonempty string", func(t *testing.T) {
|
|
168
|
+
for _, v := range []any{"", 7} {
|
|
169
|
+
assertRefuses(t, endpointMap(map[string]any{"modelsIdField": v}), "Backend modelsIdField must be a nonempty string.", refusalOptions{})
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
tw(t, "backends", "an endpoint modelsVerifyKey must be a boolean", func(t *testing.T) {
|
|
173
|
+
for _, v := range []any{"yes", 1} {
|
|
174
|
+
assertRefuses(t, endpointMap(map[string]any{"modelsVerifyKey": v}), "Backend modelsVerifyKey must be a boolean.", refusalOptions{})
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
tw(t, "backends", "an endpoint keyEnv must name an environment variable", func(t *testing.T) {
|
|
178
|
+
for _, v := range []string{"1BAD", "GATEWAY KEY", ""} {
|
|
179
|
+
assertRefuses(t, endpointMap(map[string]any{"keyEnv": v, "path": deleted}), keyEnvMessage, refusalOptions{})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
tw(t, "backends", "an endpoint keyEnv must not be TYPESAFE_API_KEY", func(t *testing.T) {
|
|
183
|
+
message := "Backend keyEnv must not be TYPESAFE_API_KEY: the TypeSafe key is only sent to the typesafe backend. Give this endpoint its own variable."
|
|
184
|
+
for _, v := range []string{"TYPESAFE_API_KEY", "typesafe_api_key"} {
|
|
185
|
+
assertRefuses(t, endpointMap(map[string]any{"keyEnv": v}), message, refusalOptions{})
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
tw(t, "backends", "an endpoint defaultModel must be a nonempty string of at most 100 characters", func(t *testing.T) {
|
|
189
|
+
for _, v := range []any{"", " ", strings.Repeat("x", 101), 7} {
|
|
190
|
+
assertRefuses(t, endpointMap(map[string]any{"defaultModel": v}), "Backend defaultModel must be a nonempty string of at most 100 characters.", refusalOptions{})
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
tw(t, "backends", "an endpoint with no defaultModel needs model on createTypeSafe", func(t *testing.T) {
|
|
194
|
+
assertRefuses(t, endpointMap(map[string]any{"defaultModel": deleted}), `Backend "Gateway" names no defaultModel; pass Model to New.`, refusalOptions{apiKey: "fake_key_0123456789abcdef"})
|
|
195
|
+
})
|
|
196
|
+
tw(t, "backends", "backend must be a registry name or a backend object", func(t *testing.T) {
|
|
197
|
+
// nil is the default backend in Go, so the original's null case has no equivalent; 42 and true remain.
|
|
198
|
+
for _, backend := range []any{42, true} {
|
|
199
|
+
assertRefuses(t, backend, "backend must be a registry name or a backend object.", refusalOptions{})
|
|
200
|
+
}
|
|
201
|
+
// An unknown name keeps the registry's own error. It lists the own-model backend this port adds.
|
|
202
|
+
assertRefuses(t, "unknown-backend", `Unknown judgment backend "unknown-backend". Valid backends: typesafe, openrouter, commandcode, ownmodel.`, refusalOptions{})
|
|
203
|
+
})
|
|
204
|
+
tw(t, "backends", "authState refuses an invalid backend instead of reporting a status", func(t *testing.T) {
|
|
205
|
+
isolate(t)
|
|
206
|
+
_, err := GetAuthState(AuthOptions{Backend: map[string]any{"label": "x", "host": "http://evil.example", "keyEnv": "K"}})
|
|
207
|
+
if !hasCode(err, CodeConfiguration) || err.Error() != hostMessage {
|
|
208
|
+
t.Fatalf("err = %v", err)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
tw(t, "backends", "an endpoint never reads TYPESAFE_API_KEY or the login store", func(t *testing.T) {
|
|
212
|
+
isolate(t)
|
|
213
|
+
t.Setenv("TYPESAFE_API_KEY", tsKey)
|
|
214
|
+
_, _ = StoreAPIKey(storedKey)
|
|
215
|
+
endpoint := gatewayEndpoint()
|
|
216
|
+
endpoint.Path = ""
|
|
217
|
+
var calls atomic.Int32
|
|
218
|
+
_, err := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { calls.Add(1); return rawResponse(200, "{}"), nil })})
|
|
219
|
+
if !hasCode(err, CodeConfiguration) || err.Error() != "No API key. Set GATEWAY_JEV_KEY in the environment." || calls.Load() != 0 {
|
|
220
|
+
t.Fatalf("err = %v", err)
|
|
221
|
+
}
|
|
222
|
+
if s, _ := KeySituationFor(gatewayEndpoint()); s.Kind != KeyMissing {
|
|
223
|
+
t.Errorf("situation = %+v", s)
|
|
224
|
+
}
|
|
225
|
+
if mustAuth(t, gatewayEndpoint()).Usable {
|
|
226
|
+
t.Error("an endpoint without its own key is unusable")
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
tw(t, "backends", "commandcode with no COMMANDCODE_API_KEY is missing and unusable", func(t *testing.T) {
|
|
230
|
+
isolate(t)
|
|
231
|
+
t.Setenv("TYPESAFE_API_KEY", tsKey)
|
|
232
|
+
_, _ = StoreAPIKey(storedKey)
|
|
233
|
+
_, err := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) { t.Error("no request may be sent"); return nil, nil })})
|
|
234
|
+
if !hasCode(err, CodeConfiguration) || err.Error() != "No API key. Set COMMANDCODE_API_KEY in the environment." {
|
|
235
|
+
t.Fatalf("err = %v", err)
|
|
236
|
+
}
|
|
237
|
+
if s, _ := KeySituationFor("commandcode"); s.Kind != KeyMissing || mustAuth(t, "commandcode").Usable {
|
|
238
|
+
t.Errorf("situation = %+v", s)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
tw(t, "backends", "a request to an endpoint carries its own key, never the TypeSafe key", func(t *testing.T) {
|
|
242
|
+
isolate(t)
|
|
243
|
+
t.Setenv("TYPESAFE_API_KEY", tsKey)
|
|
244
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
245
|
+
_, _ = StoreAPIKey(storedKey)
|
|
246
|
+
calls := 0
|
|
247
|
+
client, err := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
248
|
+
calls++
|
|
249
|
+
for name, values := range r.Header {
|
|
250
|
+
for _, v := range values {
|
|
251
|
+
if strings.Contains(v, tsKey) || strings.Contains(v, storedKey) {
|
|
252
|
+
t.Errorf("header %s carries another backend's key", name)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if r.Header.Get("Authorization") != "Bearer "+gwKey {
|
|
257
|
+
t.Errorf("authorization = %s", r.Header.Get("Authorization"))
|
|
258
|
+
}
|
|
259
|
+
return responseFor(requestBody(t, r)), nil
|
|
260
|
+
})})
|
|
261
|
+
if err != nil {
|
|
262
|
+
t.Fatal(err)
|
|
263
|
+
}
|
|
264
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil || calls != 1 {
|
|
265
|
+
t.Fatalf("err = %v", err)
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
tw(t, "backends", "a public model list on commandcode leaves the auth state unverified", func(t *testing.T) {
|
|
269
|
+
isolate(t)
|
|
270
|
+
t.Setenv("COMMANDCODE_API_KEY", ccKey)
|
|
271
|
+
client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
272
|
+
return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "typesafe/jev"}}}, nil), nil
|
|
273
|
+
})})
|
|
274
|
+
names, err := client.ListModels(context.Background())
|
|
275
|
+
if err != nil || len(names) != 1 || names[0] != "typesafe/jev" {
|
|
276
|
+
t.Fatalf("models = %v, %v", names, err)
|
|
277
|
+
}
|
|
278
|
+
if s := mustAuth(t, "commandcode"); s.Verified || s.VerifiedAt != "" {
|
|
279
|
+
t.Fatalf("state = %+v", s)
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
tw(t, "backends", "a model list on an endpoint without modelsVerifyKey leaves the auth state unverified", func(t *testing.T) {
|
|
283
|
+
isolate(t)
|
|
284
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
285
|
+
endpoint := gatewayEndpoint()
|
|
286
|
+
endpoint.Path = ""
|
|
287
|
+
client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
288
|
+
return jsonResponse(200, map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, nil), nil
|
|
289
|
+
})})
|
|
290
|
+
names, err := client.ListModels(context.Background())
|
|
291
|
+
if err != nil || len(names) != 1 || names[0] != "jev-latest" {
|
|
292
|
+
t.Fatalf("models = %v, %v", names, err)
|
|
293
|
+
}
|
|
294
|
+
if s := mustAuth(t, gatewayEndpoint()); s.Verified || s.VerifiedAt != "" {
|
|
295
|
+
t.Fatalf("state = %+v", s)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
tw(t, "backends", "an endpoint with modelsVerifyKey true records verification", func(t *testing.T) {
|
|
299
|
+
isolate(t)
|
|
300
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
301
|
+
endpoint := BackendEndpoint{BackendConfig: BackendConfig{Label: "Gateway", Host: "https://gw.example.com", KeyEnv: "GATEWAY_JEV_KEY", ModelsVerifyKey: boolPtr(true)}, DefaultModel: "jev-latest"}
|
|
302
|
+
client, _ := New(Options{Backend: endpoint, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
303
|
+
return jsonResponse(200, map[string]any{"models": []any{map[string]any{"name": "jev-latest"}}}, nil), nil
|
|
304
|
+
})})
|
|
305
|
+
if _, err := client.ListModels(context.Background()); err != nil {
|
|
306
|
+
t.Fatal(err)
|
|
307
|
+
}
|
|
308
|
+
if s := mustAuth(t, endpoint); !s.Verified || s.VerifiedAt == "" {
|
|
309
|
+
t.Fatalf("state = %+v", s)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
tw(t, "backends", "a successful evaluate records verification even after a public model list", func(t *testing.T) {
|
|
313
|
+
isolate(t)
|
|
314
|
+
t.Setenv("COMMANDCODE_API_KEY", ccKey)
|
|
315
|
+
client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
|
|
316
|
+
if strings.HasSuffix(r.URL.Path, "/models") {
|
|
317
|
+
return jsonResponse(200, map[string]any{"data": []any{map[string]any{"id": "typesafe/jev"}}}, nil), nil
|
|
318
|
+
}
|
|
319
|
+
return responseFor(requestBody(t, r)), nil
|
|
320
|
+
})})
|
|
321
|
+
_, _ = client.ListModels(context.Background())
|
|
322
|
+
if mustAuth(t, "commandcode").Verified {
|
|
323
|
+
t.Fatal("a public list must not verify")
|
|
324
|
+
}
|
|
325
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); err != nil {
|
|
326
|
+
t.Fatal(err)
|
|
327
|
+
}
|
|
328
|
+
if !mustAuth(t, "commandcode").Verified {
|
|
329
|
+
t.Fatal("a successful evaluate must verify")
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
tw(t, "backends", "a malformed reply on commandcode is a response error", func(t *testing.T) {
|
|
333
|
+
isolate(t)
|
|
334
|
+
t.Setenv("COMMANDCODE_API_KEY", ccKey)
|
|
335
|
+
client, _ := New(Options{Backend: "commandcode", HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
336
|
+
return jsonResponse(200, map[string]any{"model": "jev-test", "answers": map[string]any{}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}}, nil), nil
|
|
337
|
+
})})
|
|
338
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
|
|
339
|
+
t.Fatalf("err = %v", err)
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
tw(t, "backends", "a malformed reply on an endpoint is a response error", func(t *testing.T) {
|
|
343
|
+
isolate(t)
|
|
344
|
+
t.Setenv("GATEWAY_JEV_KEY", gwKey)
|
|
345
|
+
client, _ := New(Options{Backend: gatewayEndpoint(), HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
|
|
346
|
+
return jsonResponse(200, map[string]any{"model": "jev-test", "answers": map[string]any{"yes": map[string]any{"type": "noul", "noul": 2}}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 0}}, nil), nil
|
|
347
|
+
})})
|
|
348
|
+
if _, err := client.Evaluate(context.Background(), sampleRequest()); !hasCode(err, CodeResponse) {
|
|
349
|
+
t.Fatalf("err = %v", err)
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
tw(t, "backends", "401 advice names each backend's own key variable", func(t *testing.T) {
|
|
353
|
+
if got := SafeError(apiError(401), "commandcode").Message; got != "TypeSafe returned HTTP 401. Check COMMANDCODE_API_KEY. No automatic retry was made." {
|
|
354
|
+
t.Errorf("commandcode: %s", got)
|
|
355
|
+
}
|
|
356
|
+
if got := SafeError(apiError(401), gatewayEndpoint()).Message; got != "TypeSafe returned HTTP 401. Check GATEWAY_JEV_KEY. No automatic retry was made." {
|
|
357
|
+
t.Errorf("gateway: %s", got)
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
tw(t, "backends", "resolveBackend resolves registry names to the registry entries", func(t *testing.T) {
|
|
361
|
+
ts, _ := ResolveBackend("typesafe")
|
|
362
|
+
if ts.Name != "typesafe" || ts.Label != "TypeSafe" || ts.Host != "https://api.typesafe.ai" || ts.KeyEnv != "TYPESAFE_API_KEY" || ts.DefaultModel != "jev-latest" || !ts.ModelsVerifyKey || ts.Path != "" {
|
|
363
|
+
t.Errorf("typesafe = %+v", ts)
|
|
364
|
+
}
|
|
365
|
+
cc, _ := ResolveBackend("commandcode")
|
|
366
|
+
if cc.Name != "commandcode" || cc.Label != "Command Code" || cc.Host != "https://api.commandcode.ai" || cc.KeyEnv != "COMMANDCODE_API_KEY" || cc.Path != "/provider/v1/systemone" ||
|
|
367
|
+
cc.ModelsPath != "/provider/v1/models" || cc.ModelsField != "data" || cc.ModelsIDField != "id" || cc.DefaultModel != "typesafe/jev" || cc.ModelsVerifyKey {
|
|
368
|
+
t.Errorf("commandcode = %+v", cc)
|
|
369
|
+
}
|
|
370
|
+
if or, _ := ResolveBackend("openrouter"); or.DefaultModel != "typesafe/jev-1.13" {
|
|
371
|
+
t.Errorf("openrouter = %+v", or)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
tw(t, "backends", "backendHost reports the destination host", func(t *testing.T) {
|
|
375
|
+
cases := map[string]any{
|
|
376
|
+
"api.commandcode.ai": "commandcode", "api.typesafe.ai": "typesafe", "openrouter.ai": "openrouter", "gw.example.com": gatewayEndpoint(),
|
|
377
|
+
"gw.example.com:8443": endpointMap(map[string]any{"host": "https://gw.example.com:8443"}),
|
|
378
|
+
"127.0.0.1:8787": endpointMap(map[string]any{"host": "http://127.0.0.1:8787"}),
|
|
379
|
+
}
|
|
380
|
+
for want, backend := range cases {
|
|
381
|
+
if got, err := BackendHost(backend); err != nil || got != want {
|
|
382
|
+
t.Errorf("BackendHost(%v) = %q, %v; want %q", backend, got, err, want)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func TestOwnModelBackendResolves(t *testing.T) {
|
|
389
|
+
b, err := ResolveBackend("ownmodel")
|
|
390
|
+
if err != nil || !b.Local || b.Host != "" || b.Label == "" {
|
|
391
|
+
t.Fatalf("ownmodel = %+v, %v", b, err)
|
|
392
|
+
}
|
|
393
|
+
if host, err := BackendHost("ownmodel"); err != nil || host != "" {
|
|
394
|
+
t.Errorf("host = %q, %v", host, err)
|
|
395
|
+
}
|
|
396
|
+
isolate(t)
|
|
397
|
+
s, err := GetAuthState(AuthOptions{Backend: "ownmodel"})
|
|
398
|
+
if err != nil || s.Kind != KeyNotRequired || !s.Usable {
|
|
399
|
+
t.Fatalf("auth = %+v, %v", s, err)
|
|
400
|
+
}
|
|
401
|
+
if r, _ := DescribeAuth(s); r.Level != LevelOK || !strings.Contains(r.Text, "model PiG is configured with") {
|
|
402
|
+
t.Errorf("report = %+v", r)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
package pitypesafe
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"sync"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// DefaultConcurrency is the default number of requests in flight. TypeSafe answers in isolation, so a small pool is enough.
|
|
12
|
+
const DefaultConcurrency = 4
|
|
13
|
+
|
|
14
|
+
// Settled is one item's outcome. Skipped marks work that was never started because of a cancellation or a stop rule.
|
|
15
|
+
type Settled[T any] struct {
|
|
16
|
+
OK bool
|
|
17
|
+
Index int
|
|
18
|
+
Value T
|
|
19
|
+
Err error
|
|
20
|
+
Skipped bool
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// FanOutOptions configure FanOut.
|
|
24
|
+
type FanOutOptions struct {
|
|
25
|
+
// Concurrency is the number of requests in flight at once. Default: DefaultConcurrency.
|
|
26
|
+
Concurrency int
|
|
27
|
+
// StopOn stops launching new work once it returns true for a failure, for example a budget error.
|
|
28
|
+
StopOn func(error) bool
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// FanOut runs worker over items with bounded concurrency, preserving input order. It never fails: every item
|
|
32
|
+
// comes back as a settled result. A cancelled ctx stops starting new work; in-flight work still finishes.
|
|
33
|
+
// This is the pool the batching methods use, exported so script authors stop hand-rolling one.
|
|
34
|
+
func FanOut[I, O any](ctx context.Context, items []I, worker func(ctx context.Context, item I, index int) (O, error), opts FanOutOptions) []Settled[O] {
|
|
35
|
+
concurrency := opts.Concurrency
|
|
36
|
+
if concurrency < 1 {
|
|
37
|
+
concurrency = DefaultConcurrency
|
|
38
|
+
}
|
|
39
|
+
results := make([]*Settled[O], len(items))
|
|
40
|
+
var mu sync.Mutex
|
|
41
|
+
next := 0
|
|
42
|
+
stopped := false
|
|
43
|
+
run := func() {
|
|
44
|
+
for {
|
|
45
|
+
mu.Lock()
|
|
46
|
+
if stopped {
|
|
47
|
+
mu.Unlock()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
index := next
|
|
51
|
+
next++
|
|
52
|
+
if index >= len(items) {
|
|
53
|
+
mu.Unlock()
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if ctx.Err() != nil {
|
|
57
|
+
stopped = true
|
|
58
|
+
mu.Unlock()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
mu.Unlock()
|
|
62
|
+
value, err := worker(ctx, items[index], index)
|
|
63
|
+
mu.Lock()
|
|
64
|
+
if err != nil {
|
|
65
|
+
results[index] = &Settled[O]{Index: index, Err: err}
|
|
66
|
+
if opts.StopOn != nil && opts.StopOn(err) {
|
|
67
|
+
stopped = true
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
results[index] = &Settled[O]{OK: true, Index: index, Value: value}
|
|
71
|
+
}
|
|
72
|
+
mu.Unlock()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var wg sync.WaitGroup
|
|
76
|
+
for range min(concurrency, len(items)) {
|
|
77
|
+
wg.Add(1)
|
|
78
|
+
go func() { defer wg.Done(); run() }()
|
|
79
|
+
}
|
|
80
|
+
wg.Wait()
|
|
81
|
+
reason := "TypeSafe batch stopped after a failed request; this request was not submitted."
|
|
82
|
+
if ctx.Err() != nil {
|
|
83
|
+
reason = "TypeSafe batch cancelled before this request was submitted."
|
|
84
|
+
}
|
|
85
|
+
out := make([]Settled[O], len(items))
|
|
86
|
+
for i := range items {
|
|
87
|
+
if results[i] != nil {
|
|
88
|
+
out[i] = *results[i]
|
|
89
|
+
} else {
|
|
90
|
+
out[i] = Settled[O]{Index: i, Err: newError(CodeAborted, reason), Skipped: true}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// BatchOptions configure EvaluateMany and EvaluateAll.
|
|
97
|
+
type BatchOptions struct {
|
|
98
|
+
// Concurrency is the number of requests in flight at once. Default: DefaultConcurrency.
|
|
99
|
+
Concurrency int
|
|
100
|
+
// MaxQuestions is the chunk size for EvaluateAll. Default: DefaultMaxQuestions.
|
|
101
|
+
MaxQuestions int
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// BatchEvaluation holds per-request outcomes plus the merged view callers usually want.
|
|
105
|
+
type BatchEvaluation struct {
|
|
106
|
+
// OK is true when every request succeeded.
|
|
107
|
+
OK bool
|
|
108
|
+
// Results are per-request outcomes in input order.
|
|
109
|
+
Results []Settled[*Evaluation]
|
|
110
|
+
// Failures counts failures, including requests that were never submitted.
|
|
111
|
+
Failures int
|
|
112
|
+
// Skipped counts work never started, because of a cancellation or a budget stop.
|
|
113
|
+
Skipped int
|
|
114
|
+
// Answers are merged in input order; a repeated question id keeps the last answer. Empty when nothing succeeded.
|
|
115
|
+
Answers map[string]typesafe.Answer
|
|
116
|
+
// AnswerOrder lists the answer ids in the order they were first merged.
|
|
117
|
+
AnswerOrder []string
|
|
118
|
+
// Model is the model of the first successful request, when there is one.
|
|
119
|
+
Model string
|
|
120
|
+
// Usage is summed over the requests that succeeded.
|
|
121
|
+
Usage typesafe.Usage
|
|
122
|
+
// Elapsed is the wall-clock time for the whole batch.
|
|
123
|
+
ElapsedMs int64
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func summarize(results []Settled[*Evaluation], elapsed time.Duration) *BatchEvaluation {
|
|
127
|
+
out := &BatchEvaluation{Results: results, Answers: map[string]typesafe.Answer{}, ElapsedMs: elapsed.Milliseconds()}
|
|
128
|
+
succeeded := 0
|
|
129
|
+
for _, r := range results {
|
|
130
|
+
if !r.OK {
|
|
131
|
+
if r.Skipped {
|
|
132
|
+
out.Skipped++
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
succeeded++
|
|
137
|
+
for _, id := range r.Value.Order {
|
|
138
|
+
if _, seen := out.Answers[id]; !seen {
|
|
139
|
+
out.AnswerOrder = append(out.AnswerOrder, id)
|
|
140
|
+
}
|
|
141
|
+
out.Answers[id] = r.Value.Answers[id]
|
|
142
|
+
}
|
|
143
|
+
out.Usage.InputTokens += r.Value.Usage.InputTokens
|
|
144
|
+
out.Usage.OutputTokens += r.Value.Usage.OutputTokens
|
|
145
|
+
if out.Model == "" {
|
|
146
|
+
out.Model = r.Value.Model
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
out.OK = succeeded == len(results)
|
|
150
|
+
out.Failures = len(results) - succeeded
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// stopsBatch is a failure worth stopping the batch for: no more requests will be accepted, or the caller cancelled.
|
|
155
|
+
func stopsBatch(err error) bool {
|
|
156
|
+
ie, ok := err.(*IntegrationError)
|
|
157
|
+
return ok && (ie.Code == CodeBudget || ie.Code == CodeAborted)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Evaluator is the part of a TypeSafe client the batching functions use.
|
|
161
|
+
type Evaluator interface {
|
|
162
|
+
Evaluate(ctx context.Context, request typesafe.SystemOneRequest) (*Evaluation, error)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// EvaluateMany sends several requests with bounded concurrency, in input order, and merges what came back.
|
|
166
|
+
// Each request passes through the same admission as Evaluate, so an invalid request is one settled failure, not
|
|
167
|
+
// an error. A budget or cancellation failure stops the rest from being submitted. It never fails.
|
|
168
|
+
func EvaluateMany(ctx context.Context, client Evaluator, requests []typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
|
|
169
|
+
start := time.Now()
|
|
170
|
+
results := FanOut(ctx, requests, func(ctx context.Context, request typesafe.SystemOneRequest, _ int) (*Evaluation, error) {
|
|
171
|
+
return client.Evaluate(ctx, request)
|
|
172
|
+
}, FanOutOptions{Concurrency: opts.Concurrency, StopOn: stopsBatch})
|
|
173
|
+
return summarize(results, time.Since(start))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ChunkRequest splits a request that asks more questions than one request may carry into chunks of at most
|
|
177
|
+
// maxQuestions (default DefaultMaxQuestions). Sharing one state across several questions is one request; asking
|
|
178
|
+
// more than the per-request limit is the only reason to fan out, and the state is repeated in each chunk. The
|
|
179
|
+
// order of questions is preserved. A pure splitter: admission still happens once per chunk, in Evaluate.
|
|
180
|
+
func ChunkRequest(request typesafe.SystemOneRequest, maxQuestions int) []typesafe.SystemOneRequest {
|
|
181
|
+
limit := maxQuestions
|
|
182
|
+
if limit < 1 {
|
|
183
|
+
limit = DefaultMaxQuestions
|
|
184
|
+
}
|
|
185
|
+
if len(request.Questions) <= limit {
|
|
186
|
+
return []typesafe.SystemOneRequest{request}
|
|
187
|
+
}
|
|
188
|
+
var chunks []typesafe.SystemOneRequest
|
|
189
|
+
for i := 0; i < len(request.Questions); i += limit {
|
|
190
|
+
chunk := request
|
|
191
|
+
chunk.Questions = append(typesafe.Questions(nil), request.Questions[i:min(i+limit, len(request.Questions))]...)
|
|
192
|
+
chunks = append(chunks, chunk)
|
|
193
|
+
}
|
|
194
|
+
return chunks
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// EvaluateAll asks any number of questions about one state: chunk to the per-request limit, fan out, and merge
|
|
198
|
+
// the answers, usage, and model. Use it when one coherent state carries many independent questions; use Evaluate
|
|
199
|
+
// for one request.
|
|
200
|
+
func EvaluateAll(ctx context.Context, client Evaluator, request typesafe.SystemOneRequest, opts BatchOptions) *BatchEvaluation {
|
|
201
|
+
return EvaluateMany(ctx, client, ChunkRequest(request, opts.MaxQuestions), opts)
|
|
202
|
+
}
|