@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.
Files changed (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. 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
+ }