@pi-in-go/pigpen-jev 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 +22 -0
- package/LICENSE +22 -0
- package/README.md +237 -0
- package/extensions/jev/ask.go +166 -0
- package/extensions/jev/ask_test.go +218 -0
- package/extensions/jev/backend.go +128 -0
- package/extensions/jev/bench_test.go +64 -0
- package/extensions/jev/boundaries_test.go +159 -0
- package/extensions/jev/command.go +224 -0
- package/extensions/jev/commands_test.go +214 -0
- package/extensions/jev/config.go +450 -0
- package/extensions/jev/errors_test.go +191 -0
- package/extensions/jev/extension.go +391 -0
- package/extensions/jev/fakehost_test.go +548 -0
- package/extensions/jev/gate.go +125 -0
- package/extensions/jev/gate_test.go +610 -0
- package/extensions/jev/gatekey_test.go +24 -0
- package/extensions/jev/go.mod +9 -0
- package/extensions/jev/go.sum +2 -0
- package/extensions/jev/go.work +10 -0
- package/extensions/jev/helpers_test.go +404 -0
- package/extensions/jev/memo.go +88 -0
- package/extensions/jev/output.go +89 -0
- package/extensions/jev/output_test.go +187 -0
- package/extensions/jev/ownmodel_test.go +118 -0
- package/extensions/jev/render.go +136 -0
- package/extensions/jev/review_test.go +310 -0
- package/extensions/jev/source_test.go +57 -0
- package/extensions/jev/text.go +174 -0
- package/extensions/jev/trust_test.go +335 -0
- package/extensions/jev/types.go +227 -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 +107 -0
- package/port/e2e/gate-and-output.py +35 -0
- package/port/e2e/jev-ask.py +36 -0
- package/port/e2e/model-switch.py +44 -0
- package/port/e2e/off-by-default.py +34 -0
- package/port/gen-scenarios.py +103 -0
- package/port/golden/cache-identical-calls.jsonl +30 -0
- package/port/golden/clear.jsonl +22 -0
- package/port/golden/commands.jsonl +43 -0
- package/port/golden/enforce-accept.jsonl +23 -0
- package/port/golden/enforce-decline.jsonl +22 -0
- package/port/golden/jev-ask.jsonl +20 -0
- package/port/golden/output-advice.jsonl +23 -0
- package/port/golden/output-leak.jsonl +24 -0
- package/port/golden/output-low-confidence.jsonl +22 -0
- package/port/golden/shadow-flagged.jsonl +23 -0
- package/port/golden/unjudged-tools.jsonl +19 -0
- package/port/golden/write-elision.jsonl +21 -0
- package/port/mutate-unit.py +63 -0
- package/port/mutations.json +578 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +181 -0
- package/port/oracle/SHA256SUMS +8 -0
- package/port/oracle/package.json +43 -0
- package/port/oracle/src/client.ts +409 -0
- package/port/oracle/src/config.ts +363 -0
- package/port/oracle/src/gate.ts +229 -0
- package/port/oracle/src/index.ts +649 -0
- package/port/oracle/src/output.ts +163 -0
- package/port/red-run.log +309 -0
- package/port/scenarios/cache-identical-calls.json +71 -0
- package/port/scenarios/clear.json +61 -0
- package/port/scenarios/commands.json +119 -0
- package/port/scenarios/enforce-accept.json +66 -0
- package/port/scenarios/enforce-decline.json +57 -0
- package/port/scenarios/jev-ask.json +83 -0
- package/port/scenarios/output-advice.json +61 -0
- package/port/scenarios/output-leak.json +61 -0
- package/port/scenarios/output-low-confidence.json +61 -0
- package/port/scenarios/shadow-flagged.json +61 -0
- package/port/scenarios/unjudged-tools.json +55 -0
- package/port/scenarios/write-elision.json +53 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
package ownmodel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"go/ast"
|
|
6
|
+
"go/parser"
|
|
7
|
+
"go/token"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"sort"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// skipRules name, per upstream test file, why its cases have no Go twin. A case that is
|
|
17
|
+
// not claimed by twin()/skipTwin() must match a rule; the reasons are specific to the file.
|
|
18
|
+
var skipRules = []struct{ prefix, reason string }{
|
|
19
|
+
{"tests/test_client_with_live_apis.py::", "replays live OpenAI, Anthropic and Gemini calls from vcr cassettes; the Go backend has no provider clients. What these check (prompts and schema shape, answers) is checked by the differential goldens recorded from the oracle (TestEquivalence_GoBackendMatchesThePythonOracle); a live check needs an owner-supplied key"},
|
|
20
|
+
{"tests/test_openai_transports.py::", "OpenAI Responses and Chat Completions transports of the oracle's provider; PiG's model access (package pigmodel) replaces the transport"},
|
|
21
|
+
{"tests/test_gemini_transports.py::", "Gemini Interactions API transport of the oracle's provider; replaced by PiG's model access"},
|
|
22
|
+
{"tests/test_provider_lifecycle.py::", "close/aclose of the provider SDK clients the oracle constructs; the Go backend and pigmodel.Model own no client or connection"},
|
|
23
|
+
{"tests/test_provider_requests.py::", "the exact SDK request arguments of the oracle's OpenAI, Anthropic and Gemini providers; PiG's model access builds the provider request"},
|
|
24
|
+
{"tests/test_provider_retries.py::", "retry budgets inside the provider SDKs' HTTP clients; the transient retry policy here is the shared typesafe.Retry (tested through the Backend)"},
|
|
25
|
+
{"tests/test_provider_nonanswers.py::", "refusals, incomplete generations and output-limit stops in the provider-specific response shapes; PiG's model access reports them as a stop reason, mapped and tested in package pigmodel (TestComplete_StopReasons*)"},
|
|
26
|
+
{"tests/utils/test_error_handling.py::test_status_errors_map_and_preserve_status_and_body", "translation of the OpenAI/Anthropic/Gemini SDK exceptions to SDK errors; a Model returns typesafe errors directly"},
|
|
27
|
+
{"tests/utils/test_error_handling.py::test_timeout_and_connection_errors_map", "translation of provider SDK timeout and connection exceptions; a Model returns typesafe errors directly"},
|
|
28
|
+
{"tests/utils/test_error_handling.py::test_unknown_and_sdk_errors_pass_through", "pass-through rules of the provider SDK exception translators"},
|
|
29
|
+
{"tests/utils/test_error_handling.py::test_translating_context_manager_reraises_translated_error", "the translating() context manager of the provider layer"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestEveryUpstreamCaseHasATwinOrANamedSkip(t *testing.T) {
|
|
33
|
+
f, err := os.Open(filepath.Join("..", "..", "port", "twins", "system-one-adapter-python.txt"))
|
|
34
|
+
noErr(t, err)
|
|
35
|
+
defer f.Close()
|
|
36
|
+
var cases []string
|
|
37
|
+
sc := bufio.NewScanner(f)
|
|
38
|
+
for sc.Scan() {
|
|
39
|
+
if line := strings.TrimSpace(sc.Text()); line != "" && !strings.HasPrefix(line, "#") {
|
|
40
|
+
cases = append(cases, line)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ported, skipped := scanTwinClaims(t, ".")
|
|
44
|
+
known := map[string]bool{}
|
|
45
|
+
for _, c := range cases {
|
|
46
|
+
known[c] = true
|
|
47
|
+
}
|
|
48
|
+
var missing, dup, unknown []string
|
|
49
|
+
byRule := map[string]int{}
|
|
50
|
+
nPorted, nNamed := 0, 0
|
|
51
|
+
for _, c := range cases {
|
|
52
|
+
_, isPorted := ported[c]
|
|
53
|
+
_, isSkipped := skipped[c]
|
|
54
|
+
switch {
|
|
55
|
+
case isPorted && isSkipped:
|
|
56
|
+
dup = append(dup, c)
|
|
57
|
+
case isPorted:
|
|
58
|
+
nPorted++
|
|
59
|
+
case isSkipped:
|
|
60
|
+
nNamed++
|
|
61
|
+
default:
|
|
62
|
+
matched := false
|
|
63
|
+
for _, r := range skipRules {
|
|
64
|
+
if strings.HasPrefix(c, r.prefix) {
|
|
65
|
+
byRule[r.prefix]++
|
|
66
|
+
matched = true
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if !matched {
|
|
71
|
+
missing = append(missing, c)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for id := range ported {
|
|
76
|
+
if !known[id] {
|
|
77
|
+
unknown = append(unknown, id)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for id := range skipped {
|
|
81
|
+
if !known[id] {
|
|
82
|
+
unknown = append(unknown, id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
sort.Strings(unknown)
|
|
86
|
+
if len(missing)+len(dup)+len(unknown) > 0 {
|
|
87
|
+
t.Fatalf("%d cases without a twin or a skip rule:\n%s\nclaimed as both ported and skipped:\n%s\nunknown ids:\n%s",
|
|
88
|
+
len(missing), strings.Join(missing, "\n"), strings.Join(dup, "\n"), strings.Join(unknown, "\n"))
|
|
89
|
+
}
|
|
90
|
+
ruled := 0
|
|
91
|
+
for _, n := range byRule {
|
|
92
|
+
ruled += n
|
|
93
|
+
}
|
|
94
|
+
t.Logf("system-one-adapter-python: %d cases: %d ported twins, %d named skips, %d skipped by file rule", len(cases), nPorted, nNamed, ruled)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func scanTwinClaims(t *testing.T, dir string) (ported, skipped map[string]string) {
|
|
98
|
+
t.Helper()
|
|
99
|
+
ported, skipped = map[string]string{}, map[string]string{}
|
|
100
|
+
files, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
|
|
101
|
+
noErr(t, err)
|
|
102
|
+
fset := token.NewFileSet()
|
|
103
|
+
consts := map[string]string{}
|
|
104
|
+
var parsed []*ast.File
|
|
105
|
+
for _, file := range files {
|
|
106
|
+
f, err := parser.ParseFile(fset, file, nil, 0)
|
|
107
|
+
noErr(t, err)
|
|
108
|
+
parsed = append(parsed, f)
|
|
109
|
+
ast.Inspect(f, func(n ast.Node) bool { // string constants used to build ids
|
|
110
|
+
if vs, ok := n.(*ast.ValueSpec); ok {
|
|
111
|
+
for i, name := range vs.Names {
|
|
112
|
+
if i < len(vs.Values) {
|
|
113
|
+
if lit, ok := vs.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
|
114
|
+
s, _ := strconv.Unquote(lit.Value)
|
|
115
|
+
consts[name.Name] = s
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
var eval func(e ast.Expr) string
|
|
124
|
+
eval = func(e ast.Expr) string {
|
|
125
|
+
switch x := e.(type) {
|
|
126
|
+
case *ast.BasicLit:
|
|
127
|
+
s, err := strconv.Unquote(x.Value)
|
|
128
|
+
noErr(t, err)
|
|
129
|
+
return s
|
|
130
|
+
case *ast.Ident:
|
|
131
|
+
s, ok := consts[x.Name]
|
|
132
|
+
if !ok {
|
|
133
|
+
t.Fatalf("unknown constant %s in a twin id", x.Name)
|
|
134
|
+
}
|
|
135
|
+
return s
|
|
136
|
+
case *ast.BinaryExpr:
|
|
137
|
+
return eval(x.X) + eval(x.Y)
|
|
138
|
+
}
|
|
139
|
+
t.Fatalf("twin ids must be string literals, constants or their concatenation, got %T", e)
|
|
140
|
+
return ""
|
|
141
|
+
}
|
|
142
|
+
for _, f := range parsed {
|
|
143
|
+
ast.Inspect(f, func(n ast.Node) bool {
|
|
144
|
+
call, ok := n.(*ast.CallExpr)
|
|
145
|
+
if !ok {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
id, ok := call.Fun.(*ast.Ident)
|
|
149
|
+
if !ok || (id.Name != "twin" && id.Name != "skipTwin") {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
args := call.Args[1:]
|
|
153
|
+
reason := ""
|
|
154
|
+
dst := ported
|
|
155
|
+
if id.Name == "skipTwin" {
|
|
156
|
+
reason = eval(args[0])
|
|
157
|
+
if strings.TrimSpace(reason) == "" {
|
|
158
|
+
t.Fatalf("%s: skipTwin needs a reason", fset.Position(call.Pos()))
|
|
159
|
+
}
|
|
160
|
+
args, dst = args[1:], skipped
|
|
161
|
+
}
|
|
162
|
+
for _, a := range args {
|
|
163
|
+
dst[eval(a)] = reason
|
|
164
|
+
}
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return ported, skipped
|
|
169
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package ownmodel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"math"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func approx(t testing.TB, got, want float64) {
|
|
11
|
+
t.Helper()
|
|
12
|
+
if math.Abs(got-want) > 1e-9*math.Max(1, math.Abs(want)) {
|
|
13
|
+
t.Fatalf("got %v, want %v", got, want)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func TestConfidenceMetrics(t *testing.T) {
|
|
18
|
+
twin(t,
|
|
19
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[score_confidence-probabilities0-0.0]",
|
|
20
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[score_confidence-probabilities1-0.0]",
|
|
21
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[score_confidence-probabilities2-0.55]",
|
|
22
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[choice_confidence-probabilities3-0.0]",
|
|
23
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[choice_confidence-probabilities4-0.0]",
|
|
24
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[choice_confidence-probabilities5-0.64]",
|
|
25
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[score_confidence-probabilities6-1.0]",
|
|
26
|
+
"tests/utils/test_confidence_metrics.py::test_confidence_metrics[choice_confidence-probabilities7-1.0]")
|
|
27
|
+
for _, tc := range []struct {
|
|
28
|
+
metric func([]float64) float64
|
|
29
|
+
probs []float64
|
|
30
|
+
want float64
|
|
31
|
+
}{
|
|
32
|
+
{scoreConfidence, []float64{0.2, 0.2, 0.2, 0.2, 0.2}, 0.0},
|
|
33
|
+
{scoreConfidence, []float64{0.04, 0.04, 0.04, 0.04, 0.04}, 0.0},
|
|
34
|
+
{scoreConfidence, []float64{0.01, 0.02, 0.07, 0.3, 0.6}, 0.55},
|
|
35
|
+
{choiceConfidence, []float64{0.5, 0.5}, 0.0},
|
|
36
|
+
{choiceConfidence, []float64{0.2, 0.2}, 0.0},
|
|
37
|
+
{choiceConfidence, []float64{0.82, 0.18}, 0.64},
|
|
38
|
+
{scoreConfidence, []float64{1.0}, 1.0},
|
|
39
|
+
{choiceConfidence, []float64{1.0}, 1.0},
|
|
40
|
+
} {
|
|
41
|
+
approx(t, tc.metric(tc.probs), tc.want)
|
|
42
|
+
}
|
|
43
|
+
// A zero total falls back to a uniform distribution.
|
|
44
|
+
approx(t, choiceConfidence([]float64{0, 0}), 0)
|
|
45
|
+
approx(t, scoreConfidence([]float64{0, 0, 0}), 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestProbabilityNormalizationAndDebugData(t *testing.T) {
|
|
49
|
+
twin(t,
|
|
50
|
+
"tests/utils/test_probability_normalization.py::test_probability_normalization_and_debug_data[False-0.2-0.2-None-0.6-expected_probability_errors0]",
|
|
51
|
+
"tests/utils/test_probability_normalization.py::test_probability_normalization_and_debug_data[True-0.2-0.5-expected_originals1-0.6-expected_probability_errors1]",
|
|
52
|
+
"tests/utils/test_probability_normalization.py::test_probability_normalization_and_debug_data[False-0.50000025-0.50000025-None-5e-07-expected_probability_errors2]")
|
|
53
|
+
for _, tc := range []struct {
|
|
54
|
+
enabled bool
|
|
55
|
+
raw, want float64
|
|
56
|
+
originals bool
|
|
57
|
+
maxError float64
|
|
58
|
+
invalidCounts int
|
|
59
|
+
}{{false, 0.2, 0.2, false, 0.6, 2}, {true, 0.2, 0.5, true, 0.6, 2}, {false, 0.50000025, 0.50000025, false, 5e-7, 0}} {
|
|
60
|
+
score := normalizeProbabilities([]string{"0", "1"}, map[string]float64{"0": tc.raw, "1": tc.raw}, "", Probabilities, tc.enabled)
|
|
61
|
+
genre := normalizeProbabilities([]string{"fiction", "nonfiction"}, map[string]float64{"fiction": tc.raw, "nonfiction": tc.raw}, "", Probabilities, tc.enabled)
|
|
62
|
+
approx(t, score.Probabilities["0"], tc.want)
|
|
63
|
+
approx(t, score.Probabilities["1"], tc.want)
|
|
64
|
+
approx(t, genre.Probabilities["fiction"], tc.want)
|
|
65
|
+
d := probabilityDebugData(map[string]*probNorm{"positive": nil, "stars": &score, "genre": &genre})
|
|
66
|
+
approx(t, d.MaxError, tc.maxError)
|
|
67
|
+
eq(t, d.InvalidProbs, tc.invalidCounts)
|
|
68
|
+
eq(t, len(d.ProbabilityErrors), tc.invalidCounts)
|
|
69
|
+
for _, e := range d.ProbabilityErrors {
|
|
70
|
+
approx(t, e, tc.maxError)
|
|
71
|
+
}
|
|
72
|
+
if tc.originals {
|
|
73
|
+
eq(t, d.Original, map[string]map[string]float64{"stars": {"0": 0.2, "1": 0.2}, "genre": {"fiction": 0.2, "nonfiction": 0.2}})
|
|
74
|
+
} else if len(d.Original) != 0 {
|
|
75
|
+
t.Fatalf("no originals expected, got %v", d.Original)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestDiscreteNormalizationSelectsOneAnswer(t *testing.T) {
|
|
81
|
+
n := normalizeProbabilities([]string{"a", "b", "c"}, nil, "b", Discrete, false)
|
|
82
|
+
eq(t, n.Probabilities, map[string]float64{"a": 0, "b": 1, "c": 0})
|
|
83
|
+
eq(t, n.Error, 0.0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func TestRetriesSucceedAfterTransientError(t *testing.T) {
|
|
87
|
+
twin(t, "tests/utils/test_error_handling.py::test_retries_succeed_after_transient_error")
|
|
88
|
+
calls := 0
|
|
89
|
+
res, n, err := retryTransient(t, typesafe.DefaultRetryPolicy(), func() (string, error) {
|
|
90
|
+
calls++
|
|
91
|
+
if calls == 1 {
|
|
92
|
+
return "", providerError(503)
|
|
93
|
+
}
|
|
94
|
+
return "success", nil
|
|
95
|
+
})
|
|
96
|
+
noErr(t, err)
|
|
97
|
+
eq(t, res, "success")
|
|
98
|
+
eq(t, calls, 2)
|
|
99
|
+
eq(t, n, 1)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestNonRetryableErrorIsNotRetried(t *testing.T) {
|
|
103
|
+
twin(t, "tests/utils/test_error_handling.py::test_non_retryable_error_is_not_retried")
|
|
104
|
+
calls := 0
|
|
105
|
+
_, _, err := retryTransient(t, typesafe.DefaultRetryPolicy(), func() (string, error) { calls++; return "", providerError(400) })
|
|
106
|
+
mustAs[*typesafe.BadRequestError](t, err)
|
|
107
|
+
eq(t, calls, 1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestRetriesAreExhaustedAndReasonsRecorded(t *testing.T) {
|
|
111
|
+
twin(t, "tests/utils/test_error_handling.py::test_retries_are_exhausted_and_reasons_recorded")
|
|
112
|
+
model := newScripted(providerError(503))
|
|
113
|
+
b := mustNew(t, Options{Model: model, Retry: fastRetry(2)})
|
|
114
|
+
_, err := evaluate(t, b, "s", answerNoul("q"), nil)
|
|
115
|
+
mustAs[*typesafe.InternalServerError](t, err)
|
|
116
|
+
de := mustAs[*DebugError](t, err)
|
|
117
|
+
eq(t, categories(de.Debug.RetryReasons), []string{"provider_error", "provider_error"})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// retryTransient runs fn through a Backend-like transient retry (a helper on the Backend's loop).
|
|
121
|
+
func retryTransient(t testing.TB, p typesafe.RetryPolicy, fn func() (string, error)) (string, int, error) {
|
|
122
|
+
t.Helper()
|
|
123
|
+
p.BackoffInitial, p.BackoffJitter = durMS(1), 0
|
|
124
|
+
return runTransient(p, fn)
|
|
125
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Package pigmodel is an ownmodel.Model backed by the PiG Go SDK's model access: the
|
|
2
|
+
// model PiG is configured with, authenticated and called through the host's provider
|
|
3
|
+
// layer (Context.ModelRegistry: Find, GetApiKeyAndHeaders, Complete). No provider or
|
|
4
|
+
// model name is built in.
|
|
5
|
+
//
|
|
6
|
+
// The SDK's sdk.ModelRegistry value (what ctx.ModelRegistry() returns) satisfies [Registry] as is:
|
|
7
|
+
//
|
|
8
|
+
// info, err := ctx.GetModelInfo() // the active model; nil when none is set
|
|
9
|
+
// m, err := pigmodel.New(ctx.ModelRegistry(), pigmodel.Ref{Provider: info.Provider, ID: info.ID})
|
|
10
|
+
// backend, err := ownmodel.New(ownmodel.Options{Model: m})
|
|
11
|
+
//
|
|
12
|
+
// PiG's model access has no native structured-output mode, so this Model serves
|
|
13
|
+
// prompted mode only: a request with Structured set fails with a *typesafe.TypeSafeError.
|
|
14
|
+
package pigmodel
|
|
15
|
+
|
|
16
|
+
import (
|
|
17
|
+
"context"
|
|
18
|
+
"errors"
|
|
19
|
+
"fmt"
|
|
20
|
+
"regexp"
|
|
21
|
+
"strconv"
|
|
22
|
+
"strings"
|
|
23
|
+
"sync"
|
|
24
|
+
"time"
|
|
25
|
+
|
|
26
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel"
|
|
27
|
+
"github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Registry is the part of the PiG Go SDK's ModelRegistry this package uses.
|
|
31
|
+
type Registry interface {
|
|
32
|
+
Find(providerID, modelID string) map[string]any
|
|
33
|
+
GetApiKeyAndHeaders(model map[string]any) (map[string]any, error)
|
|
34
|
+
Complete(model, request, options map[string]any) map[string]any
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Ref names a model of the host's registry.
|
|
38
|
+
type Ref struct {
|
|
39
|
+
Provider string
|
|
40
|
+
ID string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Model is an ownmodel.Model on a [Registry].
|
|
44
|
+
type Model struct {
|
|
45
|
+
reg Registry
|
|
46
|
+
ref Ref
|
|
47
|
+
|
|
48
|
+
mu sync.Mutex
|
|
49
|
+
model map[string]any
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
var _ ownmodel.Model = (*Model)(nil)
|
|
53
|
+
|
|
54
|
+
// New returns a Model for ref. The registry is asked for the model and its auth on
|
|
55
|
+
// the first request, not here.
|
|
56
|
+
func New(reg Registry, ref Ref) (*Model, error) {
|
|
57
|
+
if reg == nil {
|
|
58
|
+
return nil, &typesafe.TypeSafeError{Message: "A model registry is required."}
|
|
59
|
+
}
|
|
60
|
+
if ref.ID == "" {
|
|
61
|
+
return nil, &typesafe.TypeSafeError{Message: "A model ID is required."}
|
|
62
|
+
}
|
|
63
|
+
return &Model{reg: reg, ref: ref}, nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Name returns the model ID.
|
|
67
|
+
func (m *Model) Name() string { return m.ref.ID }
|
|
68
|
+
|
|
69
|
+
// Complete sends the conversation through the registry. The system message becomes the
|
|
70
|
+
// request's system prompt. A stop reason of "error" is a *typesafe.APIError when the
|
|
71
|
+
// provider's message starts with an HTTP status (so the retry policy classifies it), else a
|
|
72
|
+
// retryable *typesafe.APIConnectionError carrying the provider's message; "aborted" is an
|
|
73
|
+
// *typesafe.APIUserAbortError; "length" (output limit), "toolUse" and any other non-"stop"
|
|
74
|
+
// reason are plain *typesafe.TypeSafeError values that name the reason. A cancelled
|
|
75
|
+
// ctx returns an *typesafe.APIUserAbortError without waiting for the host.
|
|
76
|
+
func (m *Model) Complete(ctx context.Context, req ownmodel.Request) (ownmodel.Result, error) {
|
|
77
|
+
if req.Structured {
|
|
78
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "PiG's model access has no native structured-output mode; use prompted mode (StructuredOutputs false)."}
|
|
79
|
+
}
|
|
80
|
+
if ctx.Err() != nil {
|
|
81
|
+
return ownmodel.Result{}, typesafe.NewAbortError(context.Cause(ctx))
|
|
82
|
+
}
|
|
83
|
+
type outcome struct {
|
|
84
|
+
res ownmodel.Result
|
|
85
|
+
err error
|
|
86
|
+
}
|
|
87
|
+
done := make(chan outcome, 1)
|
|
88
|
+
go func() {
|
|
89
|
+
res, err := m.complete(req)
|
|
90
|
+
done <- outcome{res, err}
|
|
91
|
+
}()
|
|
92
|
+
select {
|
|
93
|
+
case o := <-done:
|
|
94
|
+
return o.res, o.err
|
|
95
|
+
case <-ctx.Done():
|
|
96
|
+
// The host call cannot be interrupted from here; its result is dropped when it arrives.
|
|
97
|
+
return ownmodel.Result{}, typesafe.NewAbortError(context.Cause(ctx))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func (m *Model) resolve() (map[string]any, error) {
|
|
102
|
+
m.mu.Lock()
|
|
103
|
+
defer m.mu.Unlock()
|
|
104
|
+
if m.model != nil {
|
|
105
|
+
return m.model, nil
|
|
106
|
+
}
|
|
107
|
+
found := m.reg.Find(m.ref.Provider, m.ref.ID)
|
|
108
|
+
if found == nil {
|
|
109
|
+
return nil, &typesafe.TypeSafeError{Message: fmt.Sprintf("The model %s/%s was not found in the model registry.", m.ref.Provider, m.ref.ID)}
|
|
110
|
+
}
|
|
111
|
+
m.model = found
|
|
112
|
+
return found, nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func str(v any) string {
|
|
116
|
+
s, _ := v.(string)
|
|
117
|
+
return s
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// providerAndID reads a registry model the way the SDK does: the provider is a string or an
|
|
121
|
+
// object with an id, the model ID is "modelId" or "id".
|
|
122
|
+
func providerAndID(model map[string]any) (provider, id string) {
|
|
123
|
+
provider = str(model["provider"])
|
|
124
|
+
if provider == "" {
|
|
125
|
+
if p, ok := model["provider"].(map[string]any); ok {
|
|
126
|
+
provider = str(p["id"])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
id = str(model["modelId"])
|
|
130
|
+
if id == "" {
|
|
131
|
+
id = str(model["id"])
|
|
132
|
+
}
|
|
133
|
+
return provider, id
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func textBlocks(text string) []any {
|
|
137
|
+
return []any{map[string]any{"type": "text", "text": text}}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func zeroUsage() map[string]any {
|
|
141
|
+
return map[string]any{
|
|
142
|
+
"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
|
|
143
|
+
"cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func buildRequest(model map[string]any, msgs []ownmodel.Message) map[string]any {
|
|
148
|
+
provider, id := providerAndID(model)
|
|
149
|
+
now := time.Now().UnixMilli()
|
|
150
|
+
var system []string
|
|
151
|
+
var out []any
|
|
152
|
+
for _, msg := range msgs {
|
|
153
|
+
switch msg.Role {
|
|
154
|
+
case ownmodel.RoleSystem:
|
|
155
|
+
system = append(system, msg.Content)
|
|
156
|
+
case ownmodel.RoleAssistant:
|
|
157
|
+
out = append(out, map[string]any{
|
|
158
|
+
"role": "assistant", "content": textBlocks(msg.Content),
|
|
159
|
+
"api": str(model["api"]), "provider": provider, "model": id,
|
|
160
|
+
"usage": zeroUsage(), "stopReason": "stop", "timestamp": now,
|
|
161
|
+
})
|
|
162
|
+
default:
|
|
163
|
+
out = append(out, map[string]any{"role": "user", "content": textBlocks(msg.Content), "timestamp": now})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
req := map[string]any{"messages": out}
|
|
167
|
+
if len(system) > 0 {
|
|
168
|
+
req["systemPrompt"] = strings.Join(system, "\n\n")
|
|
169
|
+
}
|
|
170
|
+
return req
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func (m *Model) complete(req ownmodel.Request) (ownmodel.Result, error) {
|
|
174
|
+
model, err := m.resolve()
|
|
175
|
+
if err != nil {
|
|
176
|
+
return ownmodel.Result{}, err
|
|
177
|
+
}
|
|
178
|
+
auth, err := m.reg.GetApiKeyAndHeaders(model)
|
|
179
|
+
if err != nil {
|
|
180
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "Could not resolve the model's credentials: " + err.Error(), Cause: err}
|
|
181
|
+
}
|
|
182
|
+
options := map[string]any{}
|
|
183
|
+
if auth != nil {
|
|
184
|
+
if ok, present := auth["ok"].(bool); present && !ok {
|
|
185
|
+
msg := str(auth["error"])
|
|
186
|
+
if msg == "" {
|
|
187
|
+
msg = "the registry reported no credentials"
|
|
188
|
+
}
|
|
189
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "Could not resolve the model's credentials: " + msg}
|
|
190
|
+
}
|
|
191
|
+
if key := str(auth["apiKey"]); key != "" {
|
|
192
|
+
options["apiKey"] = key
|
|
193
|
+
}
|
|
194
|
+
if headers, ok := auth["headers"]; ok && headers != nil {
|
|
195
|
+
options["headers"] = headers
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
reply := m.reg.Complete(model, buildRequest(model, req.Messages), options)
|
|
199
|
+
if reply == nil {
|
|
200
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "The host returned no model result."}
|
|
201
|
+
}
|
|
202
|
+
return mapReply(reply)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
var leadingStatus = regexp.MustCompile(`^\s*([1-5]\d\d)\b\s*(.*)$`)
|
|
206
|
+
|
|
207
|
+
func toInt(v any) *int {
|
|
208
|
+
var n int
|
|
209
|
+
switch x := v.(type) {
|
|
210
|
+
case float64:
|
|
211
|
+
n = int(x)
|
|
212
|
+
case int:
|
|
213
|
+
n = x
|
|
214
|
+
case int64:
|
|
215
|
+
n = int(x)
|
|
216
|
+
default:
|
|
217
|
+
return nil
|
|
218
|
+
}
|
|
219
|
+
return &n
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// mapReply converts the terminal assistant message of the host to a Result or an error.
|
|
223
|
+
func mapReply(reply map[string]any) (ownmodel.Result, error) {
|
|
224
|
+
reason := str(reply["stopReason"])
|
|
225
|
+
switch reason {
|
|
226
|
+
case "", "stop":
|
|
227
|
+
case "error":
|
|
228
|
+
msg := strings.TrimSpace(str(reply["errorMessage"]))
|
|
229
|
+
if match := leadingStatus.FindStringSubmatch(msg); match != nil {
|
|
230
|
+
if status, _ := strconv.Atoi(match[1]); status >= 400 {
|
|
231
|
+
var body any
|
|
232
|
+
if match[2] != "" {
|
|
233
|
+
body = match[2]
|
|
234
|
+
}
|
|
235
|
+
return ownmodel.Result{}, typesafe.NewAPIError(status, body, nil)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if msg == "" {
|
|
239
|
+
msg = "the model reported an error"
|
|
240
|
+
}
|
|
241
|
+
return ownmodel.Result{}, typesafe.NewConnectionError(errors.New(msg))
|
|
242
|
+
case "aborted":
|
|
243
|
+
return ownmodel.Result{}, typesafe.NewAbortError(context.Canceled)
|
|
244
|
+
case "length":
|
|
245
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "The model stopped at its output token limit before finishing the answer. Ask fewer questions or raise the model's maximum output tokens."}
|
|
246
|
+
case "toolUse":
|
|
247
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: "The model tried to call a tool instead of answering."}
|
|
248
|
+
default:
|
|
249
|
+
return ownmodel.Result{}, &typesafe.TypeSafeError{Message: fmt.Sprintf("The model stopped with reason %q instead of answering.", reason)}
|
|
250
|
+
}
|
|
251
|
+
var text strings.Builder
|
|
252
|
+
if blocks, ok := reply["content"].([]any); ok {
|
|
253
|
+
for _, b := range blocks {
|
|
254
|
+
if block, ok := b.(map[string]any); ok && str(block["type"]) == "text" {
|
|
255
|
+
text.WriteString(str(block["text"]))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
res := ownmodel.Result{Text: text.String()}
|
|
260
|
+
if usage, ok := reply["usage"].(map[string]any); ok {
|
|
261
|
+
res.InputTokens, res.OutputTokens = toInt(usage["input"]), toInt(usage["output"])
|
|
262
|
+
}
|
|
263
|
+
return res, nil
|
|
264
|
+
}
|