@pi-in-go/pigpen-pi-typesafe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +45 -0
- package/extensions/pi-typesafe/branches_test.go +185 -0
- package/extensions/pi-typesafe/command.go +319 -0
- package/extensions/pi-typesafe/export_test.go +9 -0
- package/extensions/pi-typesafe/extension.go +188 -0
- package/extensions/pi-typesafe/extension_test.go +321 -0
- package/extensions/pi-typesafe/fakehost_test.go +548 -0
- package/extensions/pi-typesafe/format.go +191 -0
- package/extensions/pi-typesafe/format_test.go +75 -0
- package/extensions/pi-typesafe/go.mod +10 -0
- package/extensions/pi-typesafe/go.sum +2 -0
- package/extensions/pi-typesafe/go.work +11 -0
- package/extensions/pi-typesafe/harness_test.go +200 -0
- package/extensions/pi-typesafe/ownmodel_test.go +100 -0
- package/extensions/pi-typesafe/review_test.go +134 -0
- package/extensions/pi-typesafe/tool.go +193 -0
- package/extensions/pi-typesafe/twin_test.go +28 -0
- package/libs/pi-typesafe-api/CREDITS.md +14 -0
- package/libs/pi-typesafe-api/LICENSE +22 -0
- package/libs/pi-typesafe-api/README.md +30 -0
- package/libs/pi-typesafe-api/ask.go +62 -0
- package/libs/pi-typesafe-api/ask_test.go +76 -0
- package/libs/pi-typesafe-api/auth.go +249 -0
- package/libs/pi-typesafe-api/auth_test.go +131 -0
- package/libs/pi-typesafe-api/backends.go +336 -0
- package/libs/pi-typesafe-api/backends_test.go +404 -0
- package/libs/pi-typesafe-api/batch.go +202 -0
- package/libs/pi-typesafe-api/batch_test.go +202 -0
- package/libs/pi-typesafe-api/battery_test.go +41 -0
- package/libs/pi-typesafe-api/calibrate.go +354 -0
- package/libs/pi-typesafe-api/calibrate_test.go +186 -0
- package/libs/pi-typesafe-api/client.go +615 -0
- package/libs/pi-typesafe-api/client_test.go +490 -0
- package/libs/pi-typesafe-api/credentials.go +252 -0
- package/libs/pi-typesafe-api/credentials_test.go +216 -0
- package/libs/pi-typesafe-api/doc.go +14 -0
- package/libs/pi-typesafe-api/errors.go +143 -0
- package/libs/pi-typesafe-api/evaluation.go +86 -0
- package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
- package/libs/pi-typesafe-api/gaps_test.go +77 -0
- package/libs/pi-typesafe-api/go.mod +9 -0
- package/libs/pi-typesafe-api/go.sum +2 -0
- package/libs/pi-typesafe-api/helpers_test.go +169 -0
- package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
- package/libs/pi-typesafe-api/json.go +299 -0
- package/libs/pi-typesafe-api/json_test.go +92 -0
- package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
- package/libs/pi-typesafe-api/package.json +40 -0
- package/libs/pi-typesafe-api/provenance.json +18 -0
- package/libs/pi-typesafe-api/review_test.go +23 -0
- package/libs/pi-typesafe-api/schema.go +473 -0
- package/libs/pi-typesafe-api/schema_test.go +262 -0
- package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
- package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
- package/libs/pi-typesafe-api/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
- package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
- package/libs/pi-typesafe-api/ui/login.go +106 -0
- package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
- package/libs/pi-typesafe-api/usage.go +366 -0
- package/libs/pi-typesafe-api/usage_test.go +139 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +98 -0
- package/port/accepted-gaps.json +3 -0
- package/port/golden/enable-confirm.jsonl +11 -0
- package/port/golden/enable-decline.jsonl +20 -0
- package/port/golden/enable-missing-key.jsonl +4 -0
- package/port/golden/login-shadow.jsonl +4 -0
- package/port/golden/logout-env-key.jsonl +6 -0
- package/port/golden/playground-cancel.jsonl +4 -0
- package/port/golden/playground-invalid-json.jsonl +5 -0
- package/port/golden/playground-invalid-questions.jsonl +5 -0
- package/port/golden/status-env-key.jsonl +6 -0
- package/port/golden/status-no-key.jsonl +6 -0
- package/port/golden/tool-disabled.jsonl +18 -0
- package/port/golden/trailing-words.jsonl +10 -0
- package/port/library-mutations.py +44 -0
- package/port/mutations.json +302 -0
- package/port/oracle/.env.example +4 -0
- package/port/oracle/CHANGELOG.md +91 -0
- package/port/oracle/CONTRIBUTING.md +35 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +159 -0
- package/port/oracle/docs/api.md +143 -0
- package/port/oracle/docs/ci-cd.md +97 -0
- package/port/oracle/examples/decision-extension.ts +41 -0
- package/port/oracle/extensions/index.js +2 -0
- package/port/oracle/package.json +89 -0
- package/port/oracle/scripts/dev-pi.mjs +23 -0
- package/port/oracle/scripts/live-smoke.mjs +35 -0
- package/port/oracle/src/ask.ts +42 -0
- package/port/oracle/src/auth.ts +171 -0
- package/port/oracle/src/backends.ts +196 -0
- package/port/oracle/src/batch.ts +170 -0
- package/port/oracle/src/calibrate.ts +237 -0
- package/port/oracle/src/client.ts +310 -0
- package/port/oracle/src/credentials.ts +136 -0
- package/port/oracle/src/errors.ts +53 -0
- package/port/oracle/src/extension.ts +204 -0
- package/port/oracle/src/index.ts +31 -0
- package/port/oracle/src/key-prompt.ts +51 -0
- package/port/oracle/src/login.ts +60 -0
- package/port/oracle/src/schema.ts +158 -0
- package/port/oracle/src/ui.ts +4 -0
- package/port/oracle/src/usage.ts +258 -0
- package/port/oracle/tests/ask.test.ts +63 -0
- package/port/oracle/tests/auth.test.ts +141 -0
- package/port/oracle/tests/backends.test.ts +380 -0
- package/port/oracle/tests/batch.test.ts +156 -0
- package/port/oracle/tests/calibrate.test.ts +144 -0
- package/port/oracle/tests/client.test.ts +499 -0
- package/port/oracle/tests/credentials.test.ts +144 -0
- package/port/oracle/tests/extension.test.ts +276 -0
- package/port/oracle/tests/key-prompt.test.ts +47 -0
- package/port/oracle/tests/login.test.ts +101 -0
- package/port/oracle/tests/schema.test.ts +85 -0
- package/port/oracle/tests/usage.test.ts +106 -0
- package/port/oracle/tsconfig.build.json +10 -0
- package/port/oracle/tsconfig.json +14 -0
- package/port/scenarios/enable-confirm.json +5 -0
- package/port/scenarios/enable-decline.json +3 -0
- package/port/scenarios/enable-missing-key.json +2 -0
- package/port/scenarios/login-shadow.json +2 -0
- package/port/scenarios/logout-env-key.json +3 -0
- package/port/scenarios/playground-cancel.json +2 -0
- package/port/scenarios/playground-invalid-json.json +2 -0
- package/port/scenarios/playground-invalid-questions.json +2 -0
- package/port/scenarios/status-env-key.json +3 -0
- package/port/scenarios/status-no-key.json +3 -0
- package/port/scenarios/tool-disabled.json +2 -0
- package/port/scenarios/trailing-words.json +5 -0
- package/port/upstream-tests.json +160 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,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
|
+
}
|