orch-code 0.1.3 → 0.1.5
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/CHANGELOG.md +6 -0
- package/README.md +9 -0
- package/cmd/auth.go +89 -10
- package/cmd/auth_test.go +57 -4
- package/cmd/doctor.go +68 -18
- package/cmd/interactive.go +14 -2
- package/cmd/provider_model_doctor_test.go +81 -0
- package/cmd/version.go +1 -1
- package/internal/auth/account.go +27 -2
- package/internal/auth/account_session.go +154 -0
- package/internal/auth/account_session_test.go +71 -0
- package/internal/auth/store.go +477 -116
- package/internal/auth/store_test.go +73 -0
- package/internal/orchestrator/orchestrator.go +14 -2
- package/internal/providers/openai/client.go +83 -5
- package/internal/providers/openai/client_test.go +52 -0
- package/internal/providers/state_test.go +42 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## v0.1.5 - 2026-04-03
|
|
6
|
+
|
|
7
|
+
- feat: add OAuth account failover for OpenAI
|
|
8
|
+
## v0.1.4 - 2026-04-03
|
|
9
|
+
|
|
10
|
+
- feat: add live probe mode for doctor
|
|
5
11
|
## v0.1.3 - 2026-04-03
|
|
6
12
|
|
|
7
13
|
- ci: harden npm install and release flow
|
package/README.md
CHANGED
|
@@ -327,14 +327,23 @@ Or account mode (OAuth):
|
|
|
327
327
|
|
|
328
328
|
```bash
|
|
329
329
|
./orch auth login openai --method account --flow auto
|
|
330
|
+
./orch auth login openai --method account --flow auto # add another account
|
|
331
|
+
./orch auth list
|
|
332
|
+
./orch auth use <credential-id>
|
|
333
|
+
./orch auth remove <credential-id>
|
|
330
334
|
```
|
|
331
335
|
|
|
336
|
+
When multiple OpenAI OAuth accounts are stored, Orch keeps one active account and can fail over to the next local account when the active one is rate-limited or rejected.
|
|
337
|
+
|
|
332
338
|
Validate runtime readiness:
|
|
333
339
|
|
|
334
340
|
```bash
|
|
335
341
|
./orch doctor
|
|
342
|
+
./orch doctor --probe
|
|
336
343
|
```
|
|
337
344
|
|
|
345
|
+
`--probe` runs a small live OpenAI chat check, which is useful for validating account-mode OAuth auth beyond local token shape checks.
|
|
346
|
+
|
|
338
347
|
Generate a structured plan only:
|
|
339
348
|
|
|
340
349
|
```bash
|
package/cmd/auth.go
CHANGED
|
@@ -4,7 +4,6 @@ import (
|
|
|
4
4
|
"bufio"
|
|
5
5
|
"fmt"
|
|
6
6
|
"os"
|
|
7
|
-
"sort"
|
|
8
7
|
"strings"
|
|
9
8
|
"time"
|
|
10
9
|
|
|
@@ -56,6 +55,21 @@ var authLogoutCmd = &cobra.Command{
|
|
|
56
55
|
RunE: runAuthLogout,
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
var authUseCmd = &cobra.Command{
|
|
59
|
+
Use: "use <credential-id>",
|
|
60
|
+
Short: "Set the active stored credential",
|
|
61
|
+
Args: cobra.ExactArgs(1),
|
|
62
|
+
RunE: runAuthUse,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var authRemoveCmd = &cobra.Command{
|
|
66
|
+
Use: "remove <credential-id>",
|
|
67
|
+
Aliases: []string{"rm"},
|
|
68
|
+
Short: "Remove one stored credential",
|
|
69
|
+
Args: cobra.ExactArgs(1),
|
|
70
|
+
RunE: runAuthRemove,
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
var authOpenAICmd = &cobra.Command{
|
|
60
74
|
Use: "openai",
|
|
61
75
|
Hidden: true,
|
|
@@ -71,10 +85,14 @@ func init() {
|
|
|
71
85
|
authLoginCmd.Flags().StringVar(&authAPIKeyFlag, "api-key", "", "API key")
|
|
72
86
|
|
|
73
87
|
authLogoutCmd.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
|
|
88
|
+
authUseCmd.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
|
|
89
|
+
authRemoveCmd.Flags().StringVarP(&authProviderFlag, "provider", "p", "openai", "Provider id")
|
|
74
90
|
|
|
75
91
|
authCmd.AddCommand(authLoginCmd)
|
|
76
92
|
authCmd.AddCommand(authStatusCmd)
|
|
77
93
|
authCmd.AddCommand(authListCmd)
|
|
94
|
+
authCmd.AddCommand(authUseCmd)
|
|
95
|
+
authCmd.AddCommand(authRemoveCmd)
|
|
78
96
|
authCmd.AddCommand(authLogoutCmd)
|
|
79
97
|
authOpenAICmd.AddCommand(newAuthCompatLoginCmd())
|
|
80
98
|
authOpenAICmd.AddCommand(newAuthCompatLogoutCmd())
|
|
@@ -173,6 +191,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
|
|
173
191
|
|
|
174
192
|
fmt.Println("Credential saved to .orch/auth.json (0600).")
|
|
175
193
|
fmt.Printf("Auth mode set to api_key. Env %s is still supported with higher priority.\n", cfg.Provider.OpenAI.APIKeyEnv)
|
|
194
|
+
if active, activeErr := auth.Get(cwd, provider); activeErr == nil && active != nil {
|
|
195
|
+
fmt.Printf("Active credential id: %s\n", active.ID)
|
|
196
|
+
}
|
|
176
197
|
return nil
|
|
177
198
|
}
|
|
178
199
|
|
|
@@ -213,6 +234,9 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
|
|
213
234
|
|
|
214
235
|
fmt.Println("Credential saved to .orch/auth.json (0600).")
|
|
215
236
|
fmt.Printf("Auth mode set to account. You can also use %s.\n", cfg.Provider.OpenAI.AccountTokenEnv)
|
|
237
|
+
if active, activeErr := auth.Get(cwd, provider); activeErr == nil && active != nil {
|
|
238
|
+
fmt.Printf("Active credential id: %s\n", active.ID)
|
|
239
|
+
}
|
|
216
240
|
if !result.ExpiresAt.IsZero() {
|
|
217
241
|
fmt.Printf("Token expires at: %s\n", result.ExpiresAt.UTC().Format(time.RFC3339))
|
|
218
242
|
}
|
|
@@ -234,6 +258,10 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
|
|
234
258
|
if err != nil {
|
|
235
259
|
return err
|
|
236
260
|
}
|
|
261
|
+
credentials, activeID, err := auth.List(cwd, "openai")
|
|
262
|
+
if err != nil {
|
|
263
|
+
return err
|
|
264
|
+
}
|
|
237
265
|
|
|
238
266
|
fmt.Println("Auth Status")
|
|
239
267
|
fmt.Println("-----------")
|
|
@@ -251,6 +279,10 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
|
|
251
279
|
fmt.Printf("stored_api_key: %t\n", storedAPIKey)
|
|
252
280
|
fmt.Printf("stored_account_token: %t\n", storedAccount)
|
|
253
281
|
fmt.Printf("stored_account_refresh: %t\n", storedRefresh)
|
|
282
|
+
fmt.Printf("stored_credentials: %d\n", len(credentials))
|
|
283
|
+
if activeID != "" {
|
|
284
|
+
fmt.Printf("active_credential_id: %s\n", activeID)
|
|
285
|
+
}
|
|
254
286
|
if cred != nil && cred.Type == "oauth" && !cred.ExpiresAt.IsZero() {
|
|
255
287
|
fmt.Printf("account_expires_at: %s\n", cred.ExpiresAt.UTC().Format(time.RFC3339))
|
|
256
288
|
}
|
|
@@ -270,29 +302,76 @@ func runAuthList(cmd *cobra.Command, args []string) error {
|
|
|
270
302
|
return fmt.Errorf("failed to get working directory: %w", err)
|
|
271
303
|
}
|
|
272
304
|
|
|
273
|
-
|
|
305
|
+
provider := resolveProviderArg(args)
|
|
306
|
+
if provider != "openai" {
|
|
307
|
+
return fmt.Errorf("unsupported provider: %s (supported: openai)", provider)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
credentials, activeID, err := auth.List(cwd, provider)
|
|
274
311
|
if err != nil {
|
|
275
312
|
return err
|
|
276
313
|
}
|
|
277
314
|
|
|
278
315
|
fmt.Println("Stored Credentials")
|
|
279
316
|
fmt.Println("------------------")
|
|
280
|
-
if len(
|
|
317
|
+
if len(credentials) == 0 {
|
|
281
318
|
fmt.Println("No stored credentials found.")
|
|
282
319
|
return nil
|
|
283
320
|
}
|
|
284
321
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
322
|
+
for _, cred := range credentials {
|
|
323
|
+
marker := " "
|
|
324
|
+
if cred.ID == activeID {
|
|
325
|
+
marker = "*"
|
|
326
|
+
}
|
|
327
|
+
line := fmt.Sprintf("%s %s (%s)", marker, cred.ID, cred.Type)
|
|
328
|
+
if cred.Email != "" {
|
|
329
|
+
line += " " + cred.Email
|
|
330
|
+
}
|
|
331
|
+
if cred.AccountID != "" {
|
|
332
|
+
line += " account=" + cred.AccountID
|
|
333
|
+
}
|
|
334
|
+
fmt.Println(line)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return nil
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func runAuthUse(cmd *cobra.Command, args []string) error {
|
|
341
|
+
cwd, err := os.Getwd()
|
|
342
|
+
if err != nil {
|
|
343
|
+
return fmt.Errorf("failed to get working directory: %w", err)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
provider := resolveProviderArg(nil)
|
|
347
|
+
if provider != "openai" {
|
|
348
|
+
return fmt.Errorf("unsupported provider: %s (supported: openai)", provider)
|
|
349
|
+
}
|
|
350
|
+
credentialID := strings.TrimSpace(args[0])
|
|
351
|
+
if err := auth.SetActive(cwd, provider, credentialID); err != nil {
|
|
352
|
+
return err
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fmt.Printf("Active credential set to %s for %s.\n", credentialID, provider)
|
|
356
|
+
return nil
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
func runAuthRemove(cmd *cobra.Command, args []string) error {
|
|
360
|
+
cwd, err := os.Getwd()
|
|
361
|
+
if err != nil {
|
|
362
|
+
return fmt.Errorf("failed to get working directory: %w", err)
|
|
288
363
|
}
|
|
289
|
-
sort.Strings(providers)
|
|
290
364
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
fmt.
|
|
365
|
+
provider := resolveProviderArg(nil)
|
|
366
|
+
if provider != "openai" {
|
|
367
|
+
return fmt.Errorf("unsupported provider: %s (supported: openai)", provider)
|
|
368
|
+
}
|
|
369
|
+
credentialID := strings.TrimSpace(args[0])
|
|
370
|
+
if err := auth.RemoveCredential(cwd, provider, credentialID); err != nil {
|
|
371
|
+
return err
|
|
294
372
|
}
|
|
295
373
|
|
|
374
|
+
fmt.Printf("Stored credential %s removed for %s.\n", credentialID, provider)
|
|
296
375
|
return nil
|
|
297
376
|
}
|
|
298
377
|
|
package/cmd/auth_test.go
CHANGED
|
@@ -19,15 +19,27 @@ func TestAuthLoginAccountAndLogout(t *testing.T) {
|
|
|
19
19
|
t.Fatalf("save config: %v", err)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return auth.OAuthResult{
|
|
22
|
+
results := []auth.OAuthResult{
|
|
23
|
+
{
|
|
25
24
|
AccessToken: "token-123",
|
|
26
25
|
RefreshToken: "refresh-123",
|
|
27
26
|
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
|
|
28
27
|
AccountID: "acc-123",
|
|
29
28
|
Email: "oauth@example.com",
|
|
30
|
-
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
AccessToken: "token-456",
|
|
32
|
+
RefreshToken: "refresh-456",
|
|
33
|
+
ExpiresAt: time.Now().UTC().Add(2 * time.Hour),
|
|
34
|
+
AccountID: "acc-456",
|
|
35
|
+
Email: "second@example.com",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
originalOAuthRunner := runOAuthLoginFlow
|
|
39
|
+
runOAuthLoginFlow = func(flow string) (auth.OAuthResult, error) {
|
|
40
|
+
result := results[0]
|
|
41
|
+
results = results[1:]
|
|
42
|
+
return result, nil
|
|
31
43
|
}
|
|
32
44
|
defer func() {
|
|
33
45
|
runOAuthLoginFlow = originalOAuthRunner
|
|
@@ -57,6 +69,47 @@ func TestAuthLoginAccountAndLogout(t *testing.T) {
|
|
|
57
69
|
t.Fatalf("expected stored account id")
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
authEmailFlag = "second@example.com"
|
|
73
|
+
if err := runAuthLogin(nil, nil); err != nil {
|
|
74
|
+
t.Fatalf("auth login second account: %v", err)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
credentials, activeID, err := auth.List(repoRoot, "openai")
|
|
78
|
+
if err != nil {
|
|
79
|
+
t.Fatalf("list credentials: %v", err)
|
|
80
|
+
}
|
|
81
|
+
if len(credentials) != 2 {
|
|
82
|
+
t.Fatalf("expected 2 credentials, got %d", len(credentials))
|
|
83
|
+
}
|
|
84
|
+
if activeID != "acc-456" {
|
|
85
|
+
t.Fatalf("expected second account to become active, got %s", activeID)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if err := runAuthUse(nil, []string{"acc-123"}); err != nil {
|
|
89
|
+
t.Fatalf("auth use: %v", err)
|
|
90
|
+
}
|
|
91
|
+
active, err := auth.Get(repoRoot, "openai")
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatalf("get active credential: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if active == nil || active.ID != "acc-123" {
|
|
96
|
+
t.Fatalf("expected acc-123 active, got %#v", active)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if err := runAuthRemove(nil, []string{"acc-456"}); err != nil {
|
|
100
|
+
t.Fatalf("auth remove: %v", err)
|
|
101
|
+
}
|
|
102
|
+
credentials, activeID, err = auth.List(repoRoot, "openai")
|
|
103
|
+
if err != nil {
|
|
104
|
+
t.Fatalf("list credentials after remove: %v", err)
|
|
105
|
+
}
|
|
106
|
+
if len(credentials) != 1 {
|
|
107
|
+
t.Fatalf("expected 1 credential after remove, got %d", len(credentials))
|
|
108
|
+
}
|
|
109
|
+
if activeID != "acc-123" {
|
|
110
|
+
t.Fatalf("expected acc-123 to remain active, got %s", activeID)
|
|
111
|
+
}
|
|
112
|
+
|
|
60
113
|
if err := runAuthLogout(nil, nil); err != nil {
|
|
61
114
|
t.Fatalf("auth logout: %v", err)
|
|
62
115
|
}
|
package/cmd/doctor.go
CHANGED
|
@@ -9,10 +9,13 @@ import (
|
|
|
9
9
|
|
|
10
10
|
"github.com/furkanbeydemir/orch/internal/auth"
|
|
11
11
|
"github.com/furkanbeydemir/orch/internal/config"
|
|
12
|
+
"github.com/furkanbeydemir/orch/internal/providers"
|
|
12
13
|
"github.com/furkanbeydemir/orch/internal/providers/openai"
|
|
13
14
|
"github.com/spf13/cobra"
|
|
14
15
|
)
|
|
15
16
|
|
|
17
|
+
var doctorProbeFlag bool
|
|
18
|
+
|
|
16
19
|
var doctorCmd = &cobra.Command{
|
|
17
20
|
Use: "doctor",
|
|
18
21
|
Short: "Validate Orch runtime readiness",
|
|
@@ -21,6 +24,7 @@ var doctorCmd = &cobra.Command{
|
|
|
21
24
|
|
|
22
25
|
func init() {
|
|
23
26
|
rootCmd.AddCommand(doctorCmd)
|
|
27
|
+
doctorCmd.Flags().BoolVar(&doctorProbeFlag, "probe", false, "Run a live provider chat probe")
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
func runDoctor(cmd *cobra.Command, args []string) error {
|
|
@@ -85,8 +89,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|
|
85
89
|
})
|
|
86
90
|
|
|
87
91
|
checks = append(checks, check{
|
|
88
|
-
name:
|
|
89
|
-
ok:
|
|
92
|
+
name: "openai.account_refresh",
|
|
93
|
+
ok: authMode != "account" || accountToken != "" || !storedAccount || storedRefresh || (storedCred != nil && storedCred.ExpiresAt.IsZero()) || time.Now().UTC().Before(storedCred.ExpiresAt),
|
|
90
94
|
detail: fmt.Sprintf("required_when_expired=%t", authMode == "account" && accountToken == ""),
|
|
91
95
|
})
|
|
92
96
|
|
|
@@ -95,30 +99,27 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|
|
95
99
|
checks = append(checks, check{name: "openai.model.reviewer", ok: strings.TrimSpace(cfg.Provider.OpenAI.Models.Reviewer) != "", detail: cfg.Provider.OpenAI.Models.Reviewer})
|
|
96
100
|
|
|
97
101
|
if cfg.Provider.Flags.OpenAIEnabled && defaultProvider == "openai" {
|
|
98
|
-
client :=
|
|
99
|
-
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
100
|
-
_ = ctx
|
|
101
|
-
if authMode == "api_key" {
|
|
102
|
-
if storedCred != nil && strings.TrimSpace(storedCred.Key) != "" {
|
|
103
|
-
return strings.TrimSpace(storedCred.Key), nil
|
|
104
|
-
}
|
|
105
|
-
return "", nil
|
|
106
|
-
}
|
|
107
|
-
resolved, resolveErr := auth.ResolveAccountAccessToken(cwd, "openai")
|
|
108
|
-
if resolveErr != nil {
|
|
109
|
-
return "", resolveErr
|
|
110
|
-
}
|
|
111
|
-
return resolved, nil
|
|
112
|
-
})
|
|
102
|
+
client := newDoctorOpenAIClient(cwd, cfg.Provider.OpenAI, authMode, storedCred)
|
|
113
103
|
|
|
114
104
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
115
105
|
defer cancel()
|
|
116
106
|
validateErr := client.Validate(ctx)
|
|
117
107
|
checks = append(checks, check{
|
|
118
|
-
name: "openai.auth",
|
|
108
|
+
name: "openai.auth.local",
|
|
119
109
|
ok: validateErr == nil,
|
|
120
110
|
detail: errDetail(validateErr, "ok"),
|
|
121
111
|
})
|
|
112
|
+
|
|
113
|
+
if doctorProbeFlag {
|
|
114
|
+
probeCtx, probeCancel := context.WithTimeout(context.Background(), doctorProbeTimeout(cfg.Provider.OpenAI.TimeoutSeconds))
|
|
115
|
+
defer probeCancel()
|
|
116
|
+
probeErr := runOpenAIProbe(probeCtx, client, cfg.Provider.OpenAI.Models.Coder)
|
|
117
|
+
checks = append(checks, check{
|
|
118
|
+
name: "openai.auth.probe",
|
|
119
|
+
ok: probeErr == nil,
|
|
120
|
+
detail: errDetail(probeErr, "ok"),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
failed := 0
|
|
@@ -147,3 +148,52 @@ func errDetail(err error, fallback string) string {
|
|
|
147
148
|
}
|
|
148
149
|
return err.Error()
|
|
149
150
|
}
|
|
151
|
+
|
|
152
|
+
func newDoctorOpenAIClient(cwd string, cfg config.OpenAIProviderConfig, authMode string, storedCred *auth.Credential) *openai.Client {
|
|
153
|
+
client := openai.New(cfg)
|
|
154
|
+
var accountSession *auth.AccountSession
|
|
155
|
+
if authMode == "account" && strings.TrimSpace(os.Getenv(cfg.AccountTokenEnv)) == "" {
|
|
156
|
+
accountSession = auth.NewAccountSession(cwd, "openai")
|
|
157
|
+
client.SetAccountFailoverHandler(func(ctx context.Context, err error) (string, bool, error) {
|
|
158
|
+
return accountSession.Failover(ctx, openai.AccountFailoverCooldown(err), err.Error())
|
|
159
|
+
})
|
|
160
|
+
client.SetAccountSuccessHandler(func(ctx context.Context) {
|
|
161
|
+
accountSession.MarkSuccess(ctx)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
165
|
+
if authMode == "api_key" {
|
|
166
|
+
if storedCred != nil && strings.TrimSpace(storedCred.Key) != "" {
|
|
167
|
+
return strings.TrimSpace(storedCred.Key), nil
|
|
168
|
+
}
|
|
169
|
+
return "", nil
|
|
170
|
+
}
|
|
171
|
+
if accountSession == nil {
|
|
172
|
+
return "", nil
|
|
173
|
+
}
|
|
174
|
+
return accountSession.ResolveToken(ctx)
|
|
175
|
+
})
|
|
176
|
+
return client
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func runOpenAIProbe(ctx context.Context, client *openai.Client, model string) error {
|
|
180
|
+
_, err := client.Chat(ctx, providers.ChatRequest{
|
|
181
|
+
Role: providers.RoleCoder,
|
|
182
|
+
Model: strings.TrimSpace(model),
|
|
183
|
+
SystemPrompt: "Reply with OK only.",
|
|
184
|
+
UserPrompt: "ping",
|
|
185
|
+
ReasoningEffort: "low",
|
|
186
|
+
})
|
|
187
|
+
return err
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func doctorProbeTimeout(timeoutSeconds int) time.Duration {
|
|
191
|
+
if timeoutSeconds <= 0 {
|
|
192
|
+
return 20 * time.Second
|
|
193
|
+
}
|
|
194
|
+
timeout := time.Duration(timeoutSeconds) * time.Second
|
|
195
|
+
if timeout > 20*time.Second {
|
|
196
|
+
return 20 * time.Second
|
|
197
|
+
}
|
|
198
|
+
return timeout
|
|
199
|
+
}
|
package/cmd/interactive.go
CHANGED
|
@@ -961,8 +961,17 @@ func executeChatPrompt(prompt string) (*chatExecutionResult, error) {
|
|
|
961
961
|
}
|
|
962
962
|
|
|
963
963
|
client := openai.New(cfg.Provider.OpenAI)
|
|
964
|
+
var accountSession *auth.AccountSession
|
|
965
|
+
if strings.ToLower(strings.TrimSpace(cfg.Provider.OpenAI.AuthMode)) == "account" && strings.TrimSpace(os.Getenv(cfg.Provider.OpenAI.AccountTokenEnv)) == "" {
|
|
966
|
+
accountSession = auth.NewAccountSession(cwd, "openai")
|
|
967
|
+
client.SetAccountFailoverHandler(func(ctx context.Context, err error) (string, bool, error) {
|
|
968
|
+
return accountSession.Failover(ctx, openai.AccountFailoverCooldown(err), err.Error())
|
|
969
|
+
})
|
|
970
|
+
client.SetAccountSuccessHandler(func(ctx context.Context) {
|
|
971
|
+
accountSession.MarkSuccess(ctx)
|
|
972
|
+
})
|
|
973
|
+
}
|
|
964
974
|
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
965
|
-
_ = ctx
|
|
966
975
|
mode := strings.ToLower(strings.TrimSpace(cfg.Provider.OpenAI.AuthMode))
|
|
967
976
|
if mode == "api_key" {
|
|
968
977
|
cred, credErr := auth.Get(cwd, "openai")
|
|
@@ -974,7 +983,10 @@ func executeChatPrompt(prompt string) (*chatExecutionResult, error) {
|
|
|
974
983
|
}
|
|
975
984
|
return "", nil
|
|
976
985
|
}
|
|
977
|
-
|
|
986
|
+
if accountSession == nil {
|
|
987
|
+
return "", nil
|
|
988
|
+
}
|
|
989
|
+
return accountSession.ResolveToken(ctx)
|
|
978
990
|
})
|
|
979
991
|
|
|
980
992
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Provider.OpenAI.TimeoutSeconds)*time.Second)
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
package cmd
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/base64"
|
|
4
5
|
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
5
9
|
"strings"
|
|
6
10
|
"testing"
|
|
7
11
|
|
|
@@ -56,6 +60,76 @@ func TestDoctorFailsWithoutAPIKey(t *testing.T) {
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
func TestDoctorProbeAccountModeSucceeds(t *testing.T) {
|
|
64
|
+
repoRoot := t.TempDir()
|
|
65
|
+
t.Chdir(repoRoot)
|
|
66
|
+
|
|
67
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
68
|
+
if r.URL.Path != "/codex/responses" {
|
|
69
|
+
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
70
|
+
}
|
|
71
|
+
if got := r.Header.Get("ChatGPT-Account-Id"); got != "acc-123" {
|
|
72
|
+
t.Fatalf("unexpected account header: %s", got)
|
|
73
|
+
}
|
|
74
|
+
w.Header().Set("Content-Type", "application/json")
|
|
75
|
+
_, _ = w.Write([]byte(`{"output_text":"OK","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
|
|
76
|
+
}))
|
|
77
|
+
defer server.Close()
|
|
78
|
+
|
|
79
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
80
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
81
|
+
}
|
|
82
|
+
cfg := config.DefaultConfig()
|
|
83
|
+
cfg.Provider.OpenAI.AuthMode = "account"
|
|
84
|
+
cfg.Provider.OpenAI.BaseURL = server.URL
|
|
85
|
+
cfg.Provider.OpenAI.TimeoutSeconds = 5
|
|
86
|
+
if err := config.Save(repoRoot, cfg); err != nil {
|
|
87
|
+
t.Fatalf("save config: %v", err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
t.Setenv("OPENAI_ACCOUNT_TOKEN", testDoctorAccountToken("acc-123"))
|
|
91
|
+
doctorProbeFlag = true
|
|
92
|
+
defer func() { doctorProbeFlag = false }()
|
|
93
|
+
|
|
94
|
+
if err := runDoctor(nil, nil); err != nil {
|
|
95
|
+
t.Fatalf("expected doctor probe to succeed: %v", err)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestDoctorProbeAccountModeFailsWhenProviderRejects(t *testing.T) {
|
|
100
|
+
repoRoot := t.TempDir()
|
|
101
|
+
t.Chdir(repoRoot)
|
|
102
|
+
|
|
103
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
104
|
+
w.WriteHeader(http.StatusUnauthorized)
|
|
105
|
+
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
106
|
+
}))
|
|
107
|
+
defer server.Close()
|
|
108
|
+
|
|
109
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
110
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
111
|
+
}
|
|
112
|
+
cfg := config.DefaultConfig()
|
|
113
|
+
cfg.Provider.OpenAI.AuthMode = "account"
|
|
114
|
+
cfg.Provider.OpenAI.BaseURL = server.URL
|
|
115
|
+
cfg.Provider.OpenAI.TimeoutSeconds = 5
|
|
116
|
+
if err := config.Save(repoRoot, cfg); err != nil {
|
|
117
|
+
t.Fatalf("save config: %v", err)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
t.Setenv("OPENAI_ACCOUNT_TOKEN", testDoctorAccountToken("acc-123"))
|
|
121
|
+
doctorProbeFlag = true
|
|
122
|
+
defer func() { doctorProbeFlag = false }()
|
|
123
|
+
|
|
124
|
+
err := runDoctor(nil, nil)
|
|
125
|
+
if err == nil {
|
|
126
|
+
t.Fatalf("expected doctor probe failure")
|
|
127
|
+
}
|
|
128
|
+
if !strings.Contains(err.Error(), "doctor failed") {
|
|
129
|
+
t.Fatalf("unexpected doctor error: %v", err)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
59
133
|
func TestProviderListJSONOutput(t *testing.T) {
|
|
60
134
|
repoRoot := t.TempDir()
|
|
61
135
|
t.Chdir(repoRoot)
|
|
@@ -89,3 +163,10 @@ func TestProviderListJSONOutput(t *testing.T) {
|
|
|
89
163
|
t.Fatalf("expected openai in all providers, got: %#v", all)
|
|
90
164
|
}
|
|
91
165
|
}
|
|
166
|
+
|
|
167
|
+
func testDoctorAccountToken(accountID string) string {
|
|
168
|
+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
|
169
|
+
payload := fmt.Sprintf(`{"https://api.openai.com/auth":{"chatgpt_account_id":"%s"}}`, accountID)
|
|
170
|
+
body := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
171
|
+
return header + "." + body + ".sig"
|
|
172
|
+
}
|
package/cmd/version.go
CHANGED
package/internal/auth/account.go
CHANGED
|
@@ -9,12 +9,34 @@ import (
|
|
|
9
9
|
const refreshSkew = 30 * time.Second
|
|
10
10
|
|
|
11
11
|
func ResolveAccountCredential(repoRoot, provider string) (*Credential, error) {
|
|
12
|
+
return resolveAccountCredentialByID(repoRoot, provider, "")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func resolveAccountCredentialByID(repoRoot, provider, credentialID string) (*Credential, error) {
|
|
12
16
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
13
17
|
if provider == "" {
|
|
14
18
|
return nil, fmt.Errorf("provider is required")
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
var (
|
|
22
|
+
cred *Credential
|
|
23
|
+
err error
|
|
24
|
+
)
|
|
25
|
+
if strings.TrimSpace(credentialID) == "" {
|
|
26
|
+
cred, err = Get(repoRoot, provider)
|
|
27
|
+
} else {
|
|
28
|
+
credentials, _, listErr := List(repoRoot, provider)
|
|
29
|
+
if listErr != nil {
|
|
30
|
+
return nil, listErr
|
|
31
|
+
}
|
|
32
|
+
for i := range credentials {
|
|
33
|
+
if credentials[i].ID == credentialID {
|
|
34
|
+
copy := credentials[i]
|
|
35
|
+
cred = ©
|
|
36
|
+
break
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
18
40
|
if err != nil {
|
|
19
41
|
return nil, err
|
|
20
42
|
}
|
|
@@ -56,7 +78,10 @@ func ResolveAccountCredential(repoRoot, provider string) (*Credential, error) {
|
|
|
56
78
|
return nil, err
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
|
|
81
|
+
if strings.TrimSpace(credentialID) == "" {
|
|
82
|
+
return Get(repoRoot, provider)
|
|
83
|
+
}
|
|
84
|
+
return resolveAccountCredentialByID(repoRoot, provider, credentialID)
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
func ResolveAccountAccessToken(repoRoot, provider string) (string, error) {
|