@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.
Files changed (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
@@ -0,0 +1,252 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "runtime"
9
+ "strings"
10
+ )
11
+
12
+ // KeySource says where the key in effect comes from.
13
+ type KeySource string
14
+
15
+ // The key sources.
16
+ const (
17
+ SourceEnvironment KeySource = "environment"
18
+ SourceStored KeySource = "stored"
19
+ )
20
+
21
+ // KeyKind is the kind of a KeySituation.
22
+ type KeyKind string
23
+
24
+ // The key situations.
25
+ const (
26
+ KeyEnvironment KeyKind = "environment"
27
+ KeyStored KeyKind = "stored"
28
+ KeyMissing KeyKind = "missing"
29
+ KeyUnusable KeyKind = "unusable"
30
+ // KeyNotRequired is the own-model backend: it authenticates through PiG's model access, not a key of this package.
31
+ KeyNotRequired KeyKind = "notrequired"
32
+ )
33
+
34
+ // KeySituation is the complete answer to "which key is in effect".
35
+ type KeySituation struct {
36
+ Kind KeyKind
37
+ // Key is set for environment and stored.
38
+ Key string
39
+ // KeyEnv names the variable that was read (environment only).
40
+ KeyEnv string
41
+ // Path is the store path (stored and unusable).
42
+ Path string
43
+ // Reason says why a stored key cannot be used (unusable only).
44
+ Reason string
45
+ }
46
+
47
+ // AgentDir is PiG's writable agent directory: PIG_CODING_AGENT_DIR, else <PIG_HOME or ~/.pig>/agent. With
48
+ // PIG_USE_PI_DIRS=1 it is Pi's: PI_CODING_AGENT_DIR, else ~/.pi/agent. This is PiG's own rule (divergence D2),
49
+ // not the fixed Pi directory of the original.
50
+ func AgentDir() string {
51
+ if os.Getenv("PIG_USE_PI_DIRS") == "1" {
52
+ if v := os.Getenv("PI_CODING_AGENT_DIR"); v != "" {
53
+ return expandTilde(v)
54
+ }
55
+ home, _ := os.UserHomeDir()
56
+ return filepath.Join(home, ".pi", "agent")
57
+ }
58
+ if v := os.Getenv("PIG_CODING_AGENT_DIR"); v != "" {
59
+ return expandTilde(v)
60
+ }
61
+ if v := os.Getenv("PIG_HOME"); v != "" {
62
+ return filepath.Join(expandTilde(v), "agent")
63
+ }
64
+ if v := os.Getenv("XDG_CONFIG_HOME"); v != "" {
65
+ return filepath.Join(expandTilde(v), "pig", "agent")
66
+ }
67
+ home, _ := os.UserHomeDir()
68
+ return filepath.Join(home, ".pig", "agent")
69
+ }
70
+
71
+ func expandTilde(p string) string {
72
+ if p == "~" || strings.HasPrefix(p, "~/") {
73
+ home, _ := os.UserHomeDir()
74
+ return filepath.Join(home, p[1:])
75
+ }
76
+ return p
77
+ }
78
+
79
+ // TypeSafeDir is the directory every file of this package lives in.
80
+ func TypeSafeDir() string { return filepath.Join(AgentDir(), "pi-typesafe") }
81
+
82
+ // CredentialsPath is where the key lives, next to PiG's own auth.json.
83
+ func CredentialsPath() string { return filepath.Join(TypeSafeDir(), "auth.json") }
84
+
85
+ // NormalizeAPIKey accepts the key only when it is a plausible token; it never logs or echoes the value.
86
+ func NormalizeAPIKey(value any) (string, error) {
87
+ key, _ := value.(string)
88
+ key = strings.TrimSpace(key)
89
+ ok := len(key) >= 16 && len(key) <= 512
90
+ for _, r := range key {
91
+ if r < 0x21 || r > 0x7e {
92
+ ok = false
93
+ }
94
+ }
95
+ if !ok {
96
+ return "", newError(CodeValidation, "That does not look like a TypeSafe API key. Copy the complete key from console.typesafe.ai and try again; nothing was saved.")
97
+ }
98
+ return key, nil
99
+ }
100
+
101
+ // ReadStoredAPIKey returns the stored key, or "" when the file is missing, unreadable, or holds no usable
102
+ // value. It returns a configuration error when the file is readable by other users; KeySituationFor reports
103
+ // that case as unusable instead.
104
+ func ReadStoredAPIKey() (string, error) {
105
+ path := CredentialsPath()
106
+ info, err := os.Stat(path)
107
+ if err != nil {
108
+ return "", nil
109
+ }
110
+ if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
111
+ // Refuse to use a key other local users can read; the user must fix permissions or log in again.
112
+ return "", errorf(CodeConfiguration, "Refusing to read %s: it is readable by other users. Run chmod 600 on it, or run /typesafe logout and /typesafe login.", path)
113
+ }
114
+ data, err := os.ReadFile(path)
115
+ if err != nil {
116
+ return "", nil
117
+ }
118
+ var parsed map[string]any
119
+ if json.Unmarshal(data, &parsed) != nil {
120
+ return "", nil
121
+ }
122
+ if key, ok := parsed["apiKey"].(string); ok {
123
+ return strings.TrimSpace(key), nil
124
+ }
125
+ return "", nil
126
+ }
127
+
128
+ // KeySituationFor says what the environment, the login store, and file permissions add up to right now for
129
+ // one judgment backend (nil means the default). A valid backend never gets an error: the Unusable kind carries
130
+ // the user-facing reason. An invalid backend returns the same configuration error as ResolveBackend. The
131
+ // TypeSafe backend reads TYPESAFE_API_KEY, then the login store; every other backend reads only its own
132
+ // environment variable, because the store holds a TypeSafe key.
133
+ func KeySituationFor(backend any) (KeySituation, error) {
134
+ config, err := ResolveBackend(backend)
135
+ if err != nil {
136
+ return KeySituation{}, err
137
+ }
138
+ if config.Local {
139
+ return KeySituation{Kind: KeyNotRequired}, nil
140
+ }
141
+ if v := strings.TrimSpace(os.Getenv(config.KeyEnv)); v != "" {
142
+ return KeySituation{Kind: KeyEnvironment, Key: v, KeyEnv: config.KeyEnv}, nil
143
+ }
144
+ if !UsesTypeSafeKey(config.BackendConfig) {
145
+ return KeySituation{Kind: KeyMissing}, nil
146
+ }
147
+ path := CredentialsPath()
148
+ key, err := ReadStoredAPIKey()
149
+ if err != nil {
150
+ if ie, ok := err.(*IntegrationError); ok && ie.Code == CodeConfiguration {
151
+ return KeySituation{Kind: KeyUnusable, Path: path, Reason: ie.Message}, nil
152
+ }
153
+ return KeySituation{}, err
154
+ }
155
+ if key != "" {
156
+ return KeySituation{Kind: KeyStored, Key: key, Path: path}, nil
157
+ }
158
+ return KeySituation{Kind: KeyMissing}, nil
159
+ }
160
+
161
+ // KeySourceLabel is a short phrase naming the source.
162
+ func KeySourceLabel(s KeySituation) string {
163
+ switch s.Kind {
164
+ case KeyEnvironment:
165
+ if s.KeyEnv == "" {
166
+ return typesafeKeyEnv
167
+ }
168
+ return s.KeyEnv
169
+ case KeyStored:
170
+ return "/typesafe login"
171
+ case KeyMissing:
172
+ return "no key"
173
+ case KeyUnusable:
174
+ return "unusable key"
175
+ case KeyNotRequired:
176
+ return "no key needed"
177
+ }
178
+ return ""
179
+ }
180
+
181
+ // ResolvedKey is a key and where it came from.
182
+ type ResolvedKey struct {
183
+ Key string
184
+ Source KeySource
185
+ }
186
+
187
+ // ResolveAPIKey is the pre-0.4.0 key interface of the original: environment first so CI and scripts stay
188
+ // explicit, the stored key as the interactive default, nil when no key is configured, and a configuration
189
+ // error when a store must not be read. New code should call KeySituationFor.
190
+ func ResolveAPIKey(backend any) (*ResolvedKey, error) {
191
+ s, err := KeySituationFor(backend)
192
+ if err != nil {
193
+ return nil, err
194
+ }
195
+ switch s.Kind {
196
+ case KeyUnusable:
197
+ return nil, newError(CodeConfiguration, s.Reason)
198
+ case KeyEnvironment:
199
+ return &ResolvedKey{Key: s.Key, Source: SourceEnvironment}, nil
200
+ case KeyStored:
201
+ return &ResolvedKey{Key: s.Key, Source: SourceStored}, nil
202
+ }
203
+ return nil, nil
204
+ }
205
+
206
+ // StoreAPIKey validates and saves a key with owner-only permissions and returns the path.
207
+ func StoreAPIKey(value any) (string, error) {
208
+ key, err := NormalizeAPIKey(value)
209
+ if err != nil {
210
+ return "", err
211
+ }
212
+ path := CredentialsPath()
213
+ body, _ := json.MarshalIndent(map[string]string{"apiKey": key}, "", " ")
214
+ if err := writeOwnerOnly(path, append(body, '\n')); err != nil {
215
+ return "", errorf(CodeConfiguration, "Could not write %s. Check directory permissions, or set TYPESAFE_API_KEY in the environment instead.", path)
216
+ }
217
+ return path, nil
218
+ }
219
+
220
+ // writeOwnerOnly writes atomically with mode 0600 in a 0700 directory.
221
+ func writeOwnerOnly(path string, data []byte) error {
222
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
223
+ return err
224
+ }
225
+ tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
226
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
227
+ os.Remove(tmp)
228
+ return err
229
+ }
230
+ if err := os.Chmod(tmp, 0o600); err != nil {
231
+ os.Remove(tmp)
232
+ return err
233
+ }
234
+ if err := os.Rename(tmp, path); err != nil {
235
+ os.Remove(tmp)
236
+ return err
237
+ }
238
+ return nil
239
+ }
240
+
241
+ // ClearStoredAPIKey removes the stored key and reports whether there was one.
242
+ func ClearStoredAPIKey() bool {
243
+ path := CredentialsPath()
244
+ if _, err := os.Stat(path); err != nil {
245
+ return false
246
+ }
247
+ os.Remove(path)
248
+ return true
249
+ }
250
+
251
+ // EnvKeySet reports whether TYPESAFE_API_KEY is set (non-blank) in the environment.
252
+ func EnvKeySet() bool { return strings.TrimSpace(os.Getenv(typesafeKeyEnv)) != "" }
@@ -0,0 +1,216 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ const validKey = "ts_test_key_0123456789abcdef"
15
+
16
+ func isConfigError(err error, pattern string) bool {
17
+ return hasCode(err, CodeConfiguration) && regexpMatch(pattern, err.Error())
18
+ }
19
+
20
+ func TestCredentials(t *testing.T) {
21
+ tw(t, "credentials", "credentials live under Pi's agent directory", func(t *testing.T) {
22
+ dir := isolate(t)
23
+ // PiG's agent directory (PIG_CODING_AGENT_DIR), not Pi's fixed one: divergence D2, see port/PORT.md.
24
+ if CredentialsPath() != filepath.Join(dir, "pi-typesafe", "auth.json") {
25
+ t.Fatalf("path = %s", CredentialsPath())
26
+ }
27
+ t.Setenv("PIG_USE_PI_DIRS", "1")
28
+ if want := filepath.Join(os.Getenv("PI_CODING_AGENT_DIR"), "pi-typesafe", "auth.json"); CredentialsPath() != want {
29
+ t.Fatalf("with PIG_USE_PI_DIRS=1 the path is %s, got %s", want, CredentialsPath())
30
+ }
31
+ })
32
+ tw(t, "credentials", "store, read, and clear with owner-only permissions", func(t *testing.T) {
33
+ dir := isolate(t)
34
+ if got, err := ResolveAPIKey(nil); got != nil || err != nil {
35
+ t.Fatalf("empty store = %v, %v", got, err)
36
+ }
37
+ path, err := StoreAPIKey(" " + validKey + "\n")
38
+ if err != nil || path != CredentialsPath() {
39
+ t.Fatalf("store = %q, %v", path, err)
40
+ }
41
+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
42
+ t.Errorf("file mode = %v", info.Mode().Perm())
43
+ }
44
+ if info, _ := os.Stat(filepath.Join(dir, "pi-typesafe")); info.Mode().Perm() != 0o700 {
45
+ t.Errorf("dir mode = %v", info.Mode().Perm())
46
+ }
47
+ var stored map[string]string
48
+ data, _ := os.ReadFile(path)
49
+ _ = json.Unmarshal(data, &stored)
50
+ if len(stored) != 1 || stored["apiKey"] != validKey {
51
+ t.Errorf("stored = %s", data)
52
+ }
53
+ if got, _ := ResolveAPIKey(nil); got == nil || *got != (ResolvedKey{Key: validKey, Source: SourceStored}) {
54
+ t.Errorf("resolved = %+v", got)
55
+ }
56
+ if !ClearStoredAPIKey() || ClearStoredAPIKey() {
57
+ t.Error("clear must report true once, then false")
58
+ }
59
+ if k, _ := ReadStoredAPIKey(); k != "" {
60
+ t.Error("cleared key is still readable")
61
+ }
62
+ })
63
+ tw(t, "credentials", "environment variable takes precedence over the stored key", func(t *testing.T) {
64
+ isolate(t)
65
+ _, _ = StoreAPIKey(validKey)
66
+ t.Setenv("TYPESAFE_API_KEY", "env_key_0123456789abcdef")
67
+ if got, _ := ResolveAPIKey(nil); got == nil || *got != (ResolvedKey{Key: "env_key_0123456789abcdef", Source: SourceEnvironment}) {
68
+ t.Fatalf("resolved = %+v", got)
69
+ }
70
+ })
71
+ tw(t, "credentials", "implausible keys are rejected without saving", func(t *testing.T) {
72
+ isolate(t)
73
+ for _, value := range []any{"", "short", "has space in it 0123456789", "tab\tseparated0123456789", "ключ-with-non-ascii-0123456789", strings.Repeat("x", 513), 42, nil} {
74
+ _, err := StoreAPIKey(value)
75
+ text, _ := value.(string)
76
+ if !hasCode(err, CodeValidation) || (len(text) >= 4 && strings.Contains(err.Error(), text)) {
77
+ t.Errorf("StoreAPIKey(%v) = %v", value, err)
78
+ }
79
+ }
80
+ if k, _ := ReadStoredAPIKey(); k != "" {
81
+ t.Error("nothing may be saved")
82
+ }
83
+ if got, err := NormalizeAPIKey(" " + validKey + " "); err != nil || got != validKey {
84
+ t.Errorf("normalize = %q, %v", got, err)
85
+ }
86
+ })
87
+ tw(t, "credentials", "group- or world-readable credential files are refused", func(t *testing.T) {
88
+ if runtime.GOOS == "windows" {
89
+ t.Skip("POSIX permission bits")
90
+ }
91
+ isolate(t)
92
+ _, _ = StoreAPIKey(validKey)
93
+ _ = os.Chmod(CredentialsPath(), 0o644)
94
+ if _, err := ResolveAPIKey(nil); !isConfigError(err, `chmod 600`) {
95
+ t.Fatalf("err = %v", err)
96
+ }
97
+ })
98
+ tw(t, "credentials", "corrupt or unexpected files are treated as no key", func(t *testing.T) {
99
+ isolate(t)
100
+ for _, content := range []string{"not json", "[]", `{"apiKey": 5}`, "{}"} {
101
+ writeFile(t, CredentialsPath(), content, 0o600)
102
+ if k, err := ReadStoredAPIKey(); k != "" || err != nil {
103
+ t.Errorf("%q -> %q, %v", content, k, err)
104
+ }
105
+ }
106
+ })
107
+ tw(t, "credentials", "createTypeSafe uses the stored key and listModels verifies it without spending the request budget", func(t *testing.T) {
108
+ isolate(t)
109
+ _, _ = StoreAPIKey(validKey)
110
+ authorized := false
111
+ client, err := New(Options{MaxRequests: 1, HTTPClient: doerFunc(func(r *http.Request) (*http.Response, error) {
112
+ if r.URL.String() != "https://api.typesafe.ai/v1/models" {
113
+ t.Errorf("url = %s", r.URL)
114
+ }
115
+ authorized = strings.Contains(r.Header.Get("Authorization"), validKey)
116
+ return jsonResponse(200, map[string]any{"models": []any{map[string]any{"name": "jev-latest", "description": "", "release_date": "2026-01-01"}, map[string]any{"name": 7}}}, nil), nil
117
+ })})
118
+ if err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ names, err := client.ListModels(context.Background())
122
+ if err != nil || len(names) != 1 || names[0] != "jev-latest" || !authorized || client.GetUsage().RequestsStarted != 0 {
123
+ t.Fatalf("models = %v, %v, authorized=%v", names, err, authorized)
124
+ }
125
+ ClearStoredAPIKey()
126
+ if _, err := New(Options{}); !isConfigError(err, `/typesafe login`) {
127
+ t.Fatalf("err = %v", err)
128
+ }
129
+ })
130
+ tw(t, "credentials", "invalid keys fail verification with a safe message", func(t *testing.T) {
131
+ isolate(t)
132
+ client, err := New(Options{APIKey: validKey, HTTPClient: doerFunc(func(*http.Request) (*http.Response, error) {
133
+ return jsonResponse(401, map[string]any{"detail": "secret-body"}, nil), nil
134
+ })})
135
+ if err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ _, err = client.ListModels(context.Background())
139
+ ie, ok := err.(*IntegrationError)
140
+ if !ok || ie.Status != 401 || strings.Contains(ie.Message, "secret-body") {
141
+ t.Fatalf("err = %v", err)
142
+ }
143
+ })
144
+ tw(t, "credentials", "keySituation is total and names every kind", func(t *testing.T) {
145
+ if runtime.GOOS == "windows" {
146
+ t.Skip("POSIX permission bits")
147
+ }
148
+ isolate(t)
149
+ if s, _ := KeySituationFor(nil); s != (KeySituation{Kind: KeyMissing}) {
150
+ t.Fatalf("missing = %+v", s)
151
+ }
152
+ _, _ = StoreAPIKey(validKey)
153
+ if s, _ := KeySituationFor(nil); s != (KeySituation{Kind: KeyStored, Key: validKey, Path: CredentialsPath()}) {
154
+ t.Fatalf("stored = %+v", s)
155
+ }
156
+ _ = os.Chmod(CredentialsPath(), 0o644)
157
+ s, _ := KeySituationFor(nil)
158
+ if s.Kind != KeyUnusable || s.Path != CredentialsPath() || !strings.Contains(s.Reason, "chmod 600") {
159
+ t.Fatalf("unusable = %+v", s)
160
+ }
161
+ if _, err := ResolveAPIKey(nil); err == nil || err.Error() != s.Reason {
162
+ t.Fatalf("resolve error = %v", err)
163
+ }
164
+ t.Setenv("TYPESAFE_API_KEY", " "+validKey+" ")
165
+ if s, _ := KeySituationFor(nil); s != (KeySituation{Kind: KeyEnvironment, Key: validKey, KeyEnv: "TYPESAFE_API_KEY"}) {
166
+ t.Fatalf("environment = %+v", s)
167
+ }
168
+ // Environment values are trusted as-is; a wrong key fails at the API with its own advice.
169
+ t.Setenv("TYPESAFE_API_KEY", "short")
170
+ if s, _ := KeySituationFor(nil); s != (KeySituation{Kind: KeyEnvironment, Key: "short", KeyEnv: "TYPESAFE_API_KEY"}) {
171
+ t.Fatalf("short = %+v", s)
172
+ }
173
+ })
174
+ tw(t, "credentials", "keySituation for another backend reads only that backend's variable, never the TypeSafe store", func(t *testing.T) {
175
+ isolate(t)
176
+ _, _ = StoreAPIKey(validKey)
177
+ t.Setenv("TYPESAFE_API_KEY", validKey)
178
+ // A stored or TypeSafe-environment key is not an OpenRouter key.
179
+ if s, _ := KeySituationFor("openrouter"); s != (KeySituation{Kind: KeyMissing}) {
180
+ t.Fatalf("openrouter = %+v", s)
181
+ }
182
+ if got, _ := ResolveAPIKey("openrouter"); got != nil {
183
+ t.Fatalf("resolved = %+v", got)
184
+ }
185
+ t.Setenv("OPENROUTER_API_KEY", " sk-or-test-0123456789abcdef ")
186
+ s, _ := KeySituationFor("openrouter")
187
+ if s != (KeySituation{Kind: KeyEnvironment, Key: "sk-or-test-0123456789abcdef", KeyEnv: "OPENROUTER_API_KEY"}) || KeySourceLabel(s) != "OPENROUTER_API_KEY" {
188
+ t.Fatalf("openrouter env = %+v", s)
189
+ }
190
+ if got, _ := ResolveAPIKey("openrouter"); got == nil || *got != (ResolvedKey{Key: "sk-or-test-0123456789abcdef", Source: SourceEnvironment}) {
191
+ t.Fatalf("resolved = %+v", got)
192
+ }
193
+ // The OpenRouter variable does not leak into the TypeSafe resolution either.
194
+ t.Setenv("TYPESAFE_API_KEY", "")
195
+ if s, _ := KeySituationFor(nil); s.Kind != KeyStored {
196
+ t.Fatalf("typesafe = %+v", s)
197
+ }
198
+ if _, err := KeySituationFor("bogus"); !isConfigError(err, `Unknown judgment backend`) {
199
+ t.Fatalf("err = %v", err)
200
+ }
201
+ })
202
+ tw(t, "credentials", "keySourceLabel names each source", func(t *testing.T) {
203
+ cases := map[KeySituation]string{
204
+ {Kind: KeyEnvironment, Key: "k"}: "TYPESAFE_API_KEY",
205
+ {Kind: KeyEnvironment, Key: "k", KeyEnv: "OPENROUTER_API_KEY"}: "OPENROUTER_API_KEY",
206
+ {Kind: KeyStored, Key: "k", Path: "/tmp/auth.json"}: "/typesafe login",
207
+ {Kind: KeyMissing}: "no key",
208
+ {Kind: KeyUnusable, Path: "/tmp/auth.json", Reason: "r"}: "unusable key",
209
+ }
210
+ for s, want := range cases {
211
+ if got := KeySourceLabel(s); got != want {
212
+ t.Errorf("%+v -> %q, want %q", s, got, want)
213
+ }
214
+ }
215
+ })
216
+ }
@@ -0,0 +1,14 @@
1
+ // Package pitypesafe is the typed API of Pigpen's TypeSafe integration: a Go port of the
2
+ // library half of pi-typesafe (https://github.com/DevMortimer/pi-typesafe, MIT, Ryan
3
+ // Gapac). It wraps the shared client (components/typesafe) with what an extension needs
4
+ // around it: one admission rule for requests, request and spend caps that survive
5
+ // restarts, a key store and an auth state that says whether judgments are really
6
+ // happening, judgment backends (TypeSafe, OpenRouter, Command Code, a caller-supplied
7
+ // endpoint, and the model PiG is configured with), a never-throwing Ask, batching, and
8
+ // a calibration kit.
9
+ //
10
+ // Content sent through a TypeSafe, OpenRouter, Command Code or custom endpoint backend
11
+ // leaves the machine for that host; content sent through the own-model backend goes to
12
+ // the provider of the model PiG is configured with. ResolveBackend and BackendHost name
13
+ // the destination for consent text.
14
+ package pitypesafe
@@ -0,0 +1,143 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "strconv"
9
+ "strings"
10
+
11
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
12
+ )
13
+
14
+ // ErrorCode classifies an IntegrationError.
15
+ type ErrorCode string
16
+
17
+ // The error codes.
18
+ const (
19
+ CodeConfiguration ErrorCode = "configuration"
20
+ CodeValidation ErrorCode = "validation"
21
+ CodeBudget ErrorCode = "budget"
22
+ CodeAborted ErrorCode = "aborted"
23
+ CodeTimeout ErrorCode = "timeout"
24
+ CodeHTTP ErrorCode = "http"
25
+ CodeConnection ErrorCode = "connection"
26
+ CodeResponse ErrorCode = "response"
27
+ )
28
+
29
+ // IntegrationError is safe to display: it never contains upstream bodies, keys, or submitted
30
+ // state. The only header value a message can quote is a numeric Retry-After count in seconds,
31
+ // which cannot carry a secret.
32
+ type IntegrationError struct {
33
+ Code ErrorCode
34
+ Message string
35
+ // Status is the HTTP status for CodeHTTP errors, else 0.
36
+ Status int
37
+ }
38
+
39
+ // Error returns the safe message.
40
+ func (e *IntegrationError) Error() string { return e.Message }
41
+
42
+ func newError(code ErrorCode, message string) *IntegrationError {
43
+ return &IntegrationError{Code: code, Message: message}
44
+ }
45
+
46
+ func errorf(code ErrorCode, format string, args ...any) *IntegrationError {
47
+ return newError(code, fmt.Sprintf(format, args...))
48
+ }
49
+
50
+ // SafeError classifies any error into a message safe to display. backend names the key variable
51
+ // the 401 advice tells the user to check and selects the 402 wording; nil assumes the default
52
+ // TypeSafe key. An error that is already an *IntegrationError is returned as is.
53
+ func SafeError(err error, backend any) *IntegrationError {
54
+ var own *IntegrationError
55
+ if errors.As(err, &own) {
56
+ return own
57
+ }
58
+ config := errorBackend(backend)
59
+ var abort *typesafe.APIUserAbortError
60
+ if errors.As(err, &abort) || errors.Is(err, context.Canceled) {
61
+ return newError(CodeAborted, config.subject()+" request cancelled; an already submitted request may still be billed.")
62
+ }
63
+ var timeout *typesafe.APITimeoutError
64
+ if errors.As(err, &timeout) || errors.Is(err, context.DeadlineExceeded) {
65
+ return newError(CodeTimeout, config.subject()+" request timed out; it was not retried and may still be billed.")
66
+ }
67
+ var api *typesafe.APIError
68
+ if errors.As(err, &api) {
69
+ advice := httpAdvice(api.Status, api.Header, config)
70
+ e := errorf(CodeHTTP, "%s returned HTTP %d. %s No automatic retry was made.", config.subject(), api.Status, advice)
71
+ e.Status = api.Status
72
+ return e
73
+ }
74
+ var conn *typesafe.APIConnectionError
75
+ if errors.As(err, &conn) {
76
+ return newError(CodeConnection, "Could not complete the "+config.subject()+" connection. No automatic retry was made.")
77
+ }
78
+ return newError(CodeResponse, config.subject()+" returned an unreadable or unexpected response.")
79
+ }
80
+
81
+ // errorConfig is what error wording needs from a backend.
82
+ type errorConfig struct {
83
+ keyEnv string
84
+ openrouter bool
85
+ local bool
86
+ }
87
+
88
+ func (c errorConfig) subject() string {
89
+ if c.local {
90
+ return "The configured model"
91
+ }
92
+ return "TypeSafe"
93
+ }
94
+
95
+ func errorBackend(backend any) errorConfig {
96
+ if backend == nil {
97
+ return errorConfig{keyEnv: typesafeKeyEnv}
98
+ }
99
+ resolved, err := ResolveBackend(backend)
100
+ if err != nil {
101
+ return errorConfig{keyEnv: typesafeKeyEnv}
102
+ }
103
+ return errorConfig{keyEnv: resolved.KeyEnv, openrouter: resolved.Host == DecisionsBackends[BackendOpenRouter].Host, local: resolved.Local}
104
+ }
105
+
106
+ // retryAfterSeconds is a numeric Retry-After delay in seconds; dates, blanks, and anything else stay out of the message.
107
+ func retryAfterSeconds(h http.Header) (int, bool) {
108
+ raw := strings.TrimSpace(h.Get("Retry-After"))
109
+ if raw == "" {
110
+ return 0, false
111
+ }
112
+ n, err := strconv.ParseInt(raw, 10, 64)
113
+ if err != nil || n < 0 || n > 1<<53-1 {
114
+ return 0, false
115
+ }
116
+ return int(n), true
117
+ }
118
+
119
+ func httpAdvice(status int, h http.Header, c errorConfig) string {
120
+ switch status {
121
+ case 401:
122
+ if c.local {
123
+ return "Check the credentials of the configured model."
124
+ }
125
+ return "Check " + c.keyEnv + "."
126
+ case 402:
127
+ if c.openrouter {
128
+ return "Insufficient credits. Add credits at https://openrouter.ai/credits."
129
+ }
130
+ return "Check your account balance."
131
+ case 403:
132
+ return "Check your account access and model permissions."
133
+ case 429:
134
+ advice := "Check your account quota and try again later."
135
+ if secs, ok := retryAfterSeconds(h); ok {
136
+ advice += " Retry after " + strconv.Itoa(secs) + " seconds."
137
+ }
138
+ return advice
139
+ case 400, 422:
140
+ return "Check the question format and model limits."
141
+ }
142
+ return "Try again later or check the service status."
143
+ }