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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type AccountSession struct {
|
|
11
|
+
repoRoot string
|
|
12
|
+
provider string
|
|
13
|
+
currentID string
|
|
14
|
+
excluded map[string]struct{}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func NewAccountSession(repoRoot, provider string) *AccountSession {
|
|
18
|
+
return &AccountSession{
|
|
19
|
+
repoRoot: strings.TrimSpace(repoRoot),
|
|
20
|
+
provider: strings.ToLower(strings.TrimSpace(provider)),
|
|
21
|
+
excluded: map[string]struct{}{},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (s *AccountSession) ResolveToken(ctx context.Context) (string, error) {
|
|
26
|
+
_ = ctx
|
|
27
|
+
cred, err := s.pickCredential()
|
|
28
|
+
if err != nil {
|
|
29
|
+
return "", err
|
|
30
|
+
}
|
|
31
|
+
if cred == nil {
|
|
32
|
+
return "", fmt.Errorf("no active oauth credential available for provider %s", s.provider)
|
|
33
|
+
}
|
|
34
|
+
s.currentID = cred.ID
|
|
35
|
+
return strings.TrimSpace(cred.AccessToken), nil
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (s *AccountSession) Failover(ctx context.Context, cooldown time.Duration, reason string) (string, bool, error) {
|
|
39
|
+
_ = ctx
|
|
40
|
+
if strings.TrimSpace(s.currentID) == "" {
|
|
41
|
+
return "", false, nil
|
|
42
|
+
}
|
|
43
|
+
if err := mutateCredential(s.repoRoot, s.provider, s.currentID, func(cred *Credential) error {
|
|
44
|
+
if cooldown > 0 {
|
|
45
|
+
cred.CooldownUntil = time.Now().UTC().Add(cooldown)
|
|
46
|
+
}
|
|
47
|
+
cred.LastError = strings.TrimSpace(reason)
|
|
48
|
+
cred.UpdatedAt = time.Now().UTC()
|
|
49
|
+
return nil
|
|
50
|
+
}); err != nil {
|
|
51
|
+
return "", false, err
|
|
52
|
+
}
|
|
53
|
+
s.excluded[s.currentID] = struct{}{}
|
|
54
|
+
|
|
55
|
+
cred, err := s.pickCredential()
|
|
56
|
+
if err != nil {
|
|
57
|
+
return "", false, err
|
|
58
|
+
}
|
|
59
|
+
if cred == nil {
|
|
60
|
+
return "", false, nil
|
|
61
|
+
}
|
|
62
|
+
s.currentID = cred.ID
|
|
63
|
+
return strings.TrimSpace(cred.AccessToken), true, nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *AccountSession) MarkSuccess(ctx context.Context) {
|
|
67
|
+
_ = ctx
|
|
68
|
+
if strings.TrimSpace(s.currentID) == "" {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
_ = mutateCredential(s.repoRoot, s.provider, s.currentID, func(cred *Credential) error {
|
|
72
|
+
cred.LastError = ""
|
|
73
|
+
cred.CooldownUntil = time.Time{}
|
|
74
|
+
cred.LastUsedAt = time.Now().UTC()
|
|
75
|
+
cred.UpdatedAt = time.Now().UTC()
|
|
76
|
+
return nil
|
|
77
|
+
})
|
|
78
|
+
s.excluded = map[string]struct{}{}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (s *AccountSession) pickCredential() (*Credential, error) {
|
|
82
|
+
credentials, activeID, err := List(s.repoRoot, s.provider)
|
|
83
|
+
if err != nil {
|
|
84
|
+
return nil, err
|
|
85
|
+
}
|
|
86
|
+
if len(credentials) == 0 {
|
|
87
|
+
return nil, nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
now := time.Now().UTC()
|
|
91
|
+
ordered := orderCredentials(credentials, activeID, s.currentID)
|
|
92
|
+
for _, candidate := range ordered {
|
|
93
|
+
if candidate.Type != "oauth" {
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if _, skip := s.excluded[candidate.ID]; skip {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if !candidate.CooldownUntil.IsZero() && candidate.CooldownUntil.After(now) {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if candidate.ID != activeID {
|
|
103
|
+
if err := SetActive(s.repoRoot, s.provider, candidate.ID); err != nil {
|
|
104
|
+
return nil, err
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
resolved, err := resolveAccountCredentialByID(s.repoRoot, s.provider, candidate.ID)
|
|
108
|
+
if err == nil {
|
|
109
|
+
return resolved, nil
|
|
110
|
+
}
|
|
111
|
+
_ = mutateCredential(s.repoRoot, s.provider, candidate.ID, func(cred *Credential) error {
|
|
112
|
+
cred.LastError = strings.TrimSpace(err.Error())
|
|
113
|
+
cred.CooldownUntil = now.Add(5 * time.Minute)
|
|
114
|
+
cred.UpdatedAt = now
|
|
115
|
+
return nil
|
|
116
|
+
})
|
|
117
|
+
s.excluded[candidate.ID] = struct{}{}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return nil, nil
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func orderCredentials(credentials []Credential, activeID, currentID string) []Credential {
|
|
124
|
+
ordered := make([]Credential, 0, len(credentials))
|
|
125
|
+
appendByID := func(id string) {
|
|
126
|
+
if strings.TrimSpace(id) == "" {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
for _, cred := range credentials {
|
|
130
|
+
if cred.ID == id && !containsCredential(ordered, id) {
|
|
131
|
+
ordered = append(ordered, cred)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
appendByID(currentID)
|
|
137
|
+
appendByID(activeID)
|
|
138
|
+
for _, cred := range credentials {
|
|
139
|
+
if containsCredential(ordered, cred.ID) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
ordered = append(ordered, cred)
|
|
143
|
+
}
|
|
144
|
+
return ordered
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func containsCredential(credentials []Credential, credentialID string) bool {
|
|
148
|
+
for _, cred := range credentials {
|
|
149
|
+
if cred.ID == credentialID {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"fmt"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestAccountSessionFailsOverToNextCredential(t *testing.T) {
|
|
12
|
+
repoRoot := t.TempDir()
|
|
13
|
+
if err := Set(repoRoot, "openai", Credential{Type: "oauth", AccessToken: testSessionAccountToken("acc-1"), RefreshToken: "refresh-1", AccountID: "acc-1", Email: "one@example.com"}); err != nil {
|
|
14
|
+
t.Fatalf("set first account: %v", err)
|
|
15
|
+
}
|
|
16
|
+
if err := Set(repoRoot, "openai", Credential{Type: "oauth", AccessToken: testSessionAccountToken("acc-2"), RefreshToken: "refresh-2", AccountID: "acc-2", Email: "two@example.com"}); err != nil {
|
|
17
|
+
t.Fatalf("set second account: %v", err)
|
|
18
|
+
}
|
|
19
|
+
if err := SetActive(repoRoot, "openai", "acc-1"); err != nil {
|
|
20
|
+
t.Fatalf("set active: %v", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
session := NewAccountSession(repoRoot, "openai")
|
|
24
|
+
token, err := session.ResolveToken(context.Background())
|
|
25
|
+
if err != nil {
|
|
26
|
+
t.Fatalf("resolve token: %v", err)
|
|
27
|
+
}
|
|
28
|
+
if token != testSessionAccountToken("acc-1") {
|
|
29
|
+
t.Fatalf("expected first token, got %q", token)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
nextToken, switched, err := session.Failover(context.Background(), time.Minute, "rate limited")
|
|
33
|
+
if err != nil {
|
|
34
|
+
t.Fatalf("failover: %v", err)
|
|
35
|
+
}
|
|
36
|
+
if !switched {
|
|
37
|
+
t.Fatalf("expected failover switch")
|
|
38
|
+
}
|
|
39
|
+
if nextToken != testSessionAccountToken("acc-2") {
|
|
40
|
+
t.Fatalf("expected second token, got %q", nextToken)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
active, err := Get(repoRoot, "openai")
|
|
44
|
+
if err != nil {
|
|
45
|
+
t.Fatalf("get active credential: %v", err)
|
|
46
|
+
}
|
|
47
|
+
if active == nil || active.ID != "acc-2" {
|
|
48
|
+
t.Fatalf("expected acc-2 active after failover, got %#v", active)
|
|
49
|
+
}
|
|
50
|
+
credentials, _, err := List(repoRoot, "openai")
|
|
51
|
+
if err != nil {
|
|
52
|
+
t.Fatalf("list credentials: %v", err)
|
|
53
|
+
}
|
|
54
|
+
var first *Credential
|
|
55
|
+
for i := range credentials {
|
|
56
|
+
if credentials[i].ID == "acc-1" {
|
|
57
|
+
first = &credentials[i]
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if first == nil || first.CooldownUntil.IsZero() {
|
|
62
|
+
t.Fatalf("expected first credential to have cooldown set, got %#v", first)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func testSessionAccountToken(accountID string) string {
|
|
67
|
+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
|
|
68
|
+
payload := fmt.Sprintf(`{"https://api.openai.com/auth":{"chatgpt_account_id":"%s"}}`, accountID)
|
|
69
|
+
body := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
70
|
+
return header + "." + body + ".sig"
|
|
71
|
+
}
|