orch-code 0.1.4 → 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 +3 -0
- package/README.md +6 -0
- package/cmd/auth.go +89 -10
- package/cmd/auth_test.go +57 -4
- package/cmd/doctor.go +13 -5
- package/cmd/interactive.go +14 -2
- 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,73 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/furkanbeydemir/orch/internal/config"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestLoadMigratesLegacySingleCredentialMap(t *testing.T) {
|
|
14
|
+
repoRoot := t.TempDir()
|
|
15
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
16
|
+
t.Fatalf("ensure orch dir: %v", err)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
legacy := map[string]Credential{
|
|
20
|
+
"openai": {
|
|
21
|
+
Type: "oauth",
|
|
22
|
+
AccessToken: "token-123",
|
|
23
|
+
RefreshToken: "refresh-123",
|
|
24
|
+
AccountID: "acc-123",
|
|
25
|
+
Email: "user@example.com",
|
|
26
|
+
UpdatedAt: time.Now().UTC(),
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
data, err := json.MarshalIndent(legacy, "", " ")
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatalf("marshal legacy auth: %v", err)
|
|
32
|
+
}
|
|
33
|
+
path := filepath.Join(repoRoot, config.OrchDir, authFile)
|
|
34
|
+
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
35
|
+
t.Fatalf("write legacy auth: %v", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
creds, activeID, err := List(repoRoot, "openai")
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("list credentials: %v", err)
|
|
41
|
+
}
|
|
42
|
+
if len(creds) != 1 {
|
|
43
|
+
t.Fatalf("expected 1 credential, got %d", len(creds))
|
|
44
|
+
}
|
|
45
|
+
if activeID != "acc-123" {
|
|
46
|
+
t.Fatalf("expected acc-123 active, got %s", activeID)
|
|
47
|
+
}
|
|
48
|
+
if creds[0].ID != "acc-123" {
|
|
49
|
+
t.Fatalf("expected migrated id acc-123, got %s", creds[0].ID)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestSetActiveSwitchesReturnedCredential(t *testing.T) {
|
|
54
|
+
repoRoot := t.TempDir()
|
|
55
|
+
if err := Set(repoRoot, "openai", Credential{Type: "oauth", AccessToken: "token-1", RefreshToken: "refresh-1", AccountID: "acc-1", Email: "one@example.com"}); err != nil {
|
|
56
|
+
t.Fatalf("set first credential: %v", err)
|
|
57
|
+
}
|
|
58
|
+
if err := Set(repoRoot, "openai", Credential{Type: "oauth", AccessToken: "token-2", RefreshToken: "refresh-2", AccountID: "acc-2", Email: "two@example.com"}); err != nil {
|
|
59
|
+
t.Fatalf("set second credential: %v", err)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if err := SetActive(repoRoot, "openai", "acc-1"); err != nil {
|
|
63
|
+
t.Fatalf("set active: %v", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
active, err := Get(repoRoot, "openai")
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatalf("get active credential: %v", err)
|
|
69
|
+
}
|
|
70
|
+
if active == nil || active.ID != "acc-1" {
|
|
71
|
+
t.Fatalf("expected acc-1 active, got %#v", active)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -99,8 +99,17 @@ func (o *Orchestrator) attachProviderRuntime() {
|
|
|
99
99
|
|
|
100
100
|
registry := providers.NewRegistry()
|
|
101
101
|
client := openai.New(o.cfg.Provider.OpenAI)
|
|
102
|
+
var accountSession *auth.AccountSession
|
|
103
|
+
if strings.ToLower(strings.TrimSpace(o.cfg.Provider.OpenAI.AuthMode)) == "account" && strings.TrimSpace(os.Getenv(o.cfg.Provider.OpenAI.AccountTokenEnv)) == "" {
|
|
104
|
+
accountSession = auth.NewAccountSession(o.repoRoot, "openai")
|
|
105
|
+
client.SetAccountFailoverHandler(func(ctx context.Context, err error) (string, bool, error) {
|
|
106
|
+
return accountSession.Failover(ctx, openai.AccountFailoverCooldown(err), err.Error())
|
|
107
|
+
})
|
|
108
|
+
client.SetAccountSuccessHandler(func(ctx context.Context) {
|
|
109
|
+
accountSession.MarkSuccess(ctx)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
102
112
|
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
103
|
-
_ = ctx
|
|
104
113
|
if strings.ToLower(strings.TrimSpace(o.cfg.Provider.OpenAI.AuthMode)) == "api_key" {
|
|
105
114
|
cred, err := auth.Get(o.repoRoot, "openai")
|
|
106
115
|
if err != nil || cred == nil {
|
|
@@ -111,7 +120,10 @@ func (o *Orchestrator) attachProviderRuntime() {
|
|
|
111
120
|
}
|
|
112
121
|
return "", nil
|
|
113
122
|
}
|
|
114
|
-
|
|
123
|
+
if accountSession == nil {
|
|
124
|
+
return "", nil
|
|
125
|
+
}
|
|
126
|
+
return accountSession.ResolveToken(ctx)
|
|
115
127
|
})
|
|
116
128
|
registry.Register(client)
|
|
117
129
|
router := providers.NewRouter(o.cfg, registry)
|
|
@@ -18,10 +18,12 @@ import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
type Client struct {
|
|
21
|
-
cfg
|
|
22
|
-
httpClient
|
|
23
|
-
rand
|
|
24
|
-
resolveToken
|
|
21
|
+
cfg config.OpenAIProviderConfig
|
|
22
|
+
httpClient *http.Client
|
|
23
|
+
rand *rand.Rand
|
|
24
|
+
resolveToken func(context.Context) (string, error)
|
|
25
|
+
accountFailover func(context.Context, error) (string, bool, error)
|
|
26
|
+
accountSuccess func(context.Context)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
type requester interface {
|
|
@@ -49,6 +51,13 @@ func New(cfg config.OpenAIProviderConfig) *Client {
|
|
|
49
51
|
_ = ctx
|
|
50
52
|
return "", nil
|
|
51
53
|
},
|
|
54
|
+
accountFailover: func(ctx context.Context, err error) (string, bool, error) {
|
|
55
|
+
_, _ = ctx, err
|
|
56
|
+
return "", false, nil
|
|
57
|
+
},
|
|
58
|
+
accountSuccess: func(ctx context.Context) {
|
|
59
|
+
_ = ctx
|
|
60
|
+
},
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -59,6 +68,20 @@ func (c *Client) SetTokenResolver(resolver func(context.Context) (string, error)
|
|
|
59
68
|
c.resolveToken = resolver
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
func (c *Client) SetAccountFailoverHandler(handler func(context.Context, error) (string, bool, error)) {
|
|
72
|
+
if handler == nil {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
c.accountFailover = handler
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func (c *Client) SetAccountSuccessHandler(handler func(context.Context)) {
|
|
79
|
+
if handler == nil {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
c.accountSuccess = handler
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
func (c *Client) Name() string {
|
|
63
86
|
return "openai"
|
|
64
87
|
}
|
|
@@ -124,11 +147,11 @@ func (c *Client) Stream(ctx context.Context, req providers.ChatRequest) (<-chan
|
|
|
124
147
|
}
|
|
125
148
|
|
|
126
149
|
func (c *Client) chatWithDoer(ctx context.Context, req providers.ChatRequest, doer requester) (providers.ChatResponse, error) {
|
|
150
|
+
mode := c.authMode()
|
|
127
151
|
key, err := c.resolveAuthToken(ctx)
|
|
128
152
|
if err != nil {
|
|
129
153
|
return providers.ChatResponse{}, err
|
|
130
154
|
}
|
|
131
|
-
mode := c.authMode()
|
|
132
155
|
|
|
133
156
|
model := strings.TrimSpace(req.Model)
|
|
134
157
|
if model == "" {
|
|
@@ -176,6 +199,12 @@ func (c *Client) chatWithDoer(ctx context.Context, req providers.ChatRequest, do
|
|
|
176
199
|
if doErr != nil {
|
|
177
200
|
mapped := mapHTTPError(doErr, 0, "chat")
|
|
178
201
|
lastErr = mapped
|
|
202
|
+
if nextToken, switched, switchErr := c.maybeFailoverAccount(ctx, mode, mapped); switchErr != nil {
|
|
203
|
+
return providers.ChatResponse{}, switchErr
|
|
204
|
+
} else if switched {
|
|
205
|
+
key = nextToken
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
179
208
|
if isRetryable(mapped) && attempt < attempts {
|
|
180
209
|
c.sleepBackoff(attempt)
|
|
181
210
|
continue
|
|
@@ -197,6 +226,12 @@ func (c *Client) chatWithDoer(ctx context.Context, req providers.ChatRequest, do
|
|
|
197
226
|
if httpResp.StatusCode >= 300 {
|
|
198
227
|
mapped := mapStatusError(httpResp.StatusCode, string(data), "chat")
|
|
199
228
|
lastErr = mapped
|
|
229
|
+
if nextToken, switched, switchErr := c.maybeFailoverAccount(ctx, mode, mapped); switchErr != nil {
|
|
230
|
+
return providers.ChatResponse{}, switchErr
|
|
231
|
+
} else if switched {
|
|
232
|
+
key = nextToken
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
200
235
|
if isRetryable(mapped) && attempt < attempts {
|
|
201
236
|
c.sleepBackoff(attempt)
|
|
202
237
|
continue
|
|
@@ -218,6 +253,12 @@ func (c *Client) chatWithDoer(ctx context.Context, req providers.ChatRequest, do
|
|
|
218
253
|
"provider": "openai",
|
|
219
254
|
"model": model,
|
|
220
255
|
}
|
|
256
|
+
if mode == "account" {
|
|
257
|
+
if accountID, accountErr := extractAccountID(key); accountErr == nil {
|
|
258
|
+
parsed.ProviderMetadata["account_id"] = accountID
|
|
259
|
+
}
|
|
260
|
+
c.accountSuccess(ctx)
|
|
261
|
+
}
|
|
221
262
|
return parsed, nil
|
|
222
263
|
}
|
|
223
264
|
|
|
@@ -228,6 +269,43 @@ func (c *Client) chatWithDoer(ctx context.Context, req providers.ChatRequest, do
|
|
|
228
269
|
return providers.ChatResponse{}, lastErr
|
|
229
270
|
}
|
|
230
271
|
|
|
272
|
+
func (c *Client) maybeFailoverAccount(ctx context.Context, mode string, err error) (string, bool, error) {
|
|
273
|
+
if mode != "account" || !isAccountFailoverEligible(err) {
|
|
274
|
+
return "", false, nil
|
|
275
|
+
}
|
|
276
|
+
return c.accountFailover(ctx, err)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func isAccountFailoverEligible(err error) bool {
|
|
280
|
+
pe, ok := err.(*providers.Error)
|
|
281
|
+
if !ok {
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
284
|
+
switch pe.Code {
|
|
285
|
+
case providers.ErrRateLimited, providers.ErrAuthError, providers.ErrModelUnavailable:
|
|
286
|
+
return true
|
|
287
|
+
default:
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func AccountFailoverCooldown(err error) time.Duration {
|
|
293
|
+
pe, ok := err.(*providers.Error)
|
|
294
|
+
if !ok {
|
|
295
|
+
return 0
|
|
296
|
+
}
|
|
297
|
+
switch pe.Code {
|
|
298
|
+
case providers.ErrRateLimited:
|
|
299
|
+
return 2 * time.Minute
|
|
300
|
+
case providers.ErrAuthError:
|
|
301
|
+
return 15 * time.Minute
|
|
302
|
+
case providers.ErrModelUnavailable:
|
|
303
|
+
return 10 * time.Minute
|
|
304
|
+
default:
|
|
305
|
+
return 0
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
231
309
|
func (c *Client) defaultModel(role providers.Role) string {
|
|
232
310
|
switch role {
|
|
233
311
|
case providers.RolePlanner:
|
|
@@ -70,6 +70,58 @@ func TestChatRetriesOnRateLimit(t *testing.T) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
func TestChatAccountModeFailsOverToSecondToken(t *testing.T) {
|
|
74
|
+
client := New(config.OpenAIProviderConfig{
|
|
75
|
+
AuthMode: "account",
|
|
76
|
+
BaseURL: "https://api.openai.com/v1",
|
|
77
|
+
AccountTokenEnv: "OPENAI_ACCOUNT_TOKEN",
|
|
78
|
+
MaxRetries: 2,
|
|
79
|
+
Models: config.ProviderRoleModels{Coder: "gpt-5.3-codex"},
|
|
80
|
+
})
|
|
81
|
+
firstToken := testAccountToken("acc-1")
|
|
82
|
+
secondToken := testAccountToken("acc-2")
|
|
83
|
+
client.SetTokenResolver(func(ctx context.Context) (string, error) {
|
|
84
|
+
return firstToken, nil
|
|
85
|
+
})
|
|
86
|
+
client.SetAccountFailoverHandler(func(ctx context.Context, err error) (string, bool, error) {
|
|
87
|
+
return secondToken, true, nil
|
|
88
|
+
})
|
|
89
|
+
markedSuccess := false
|
|
90
|
+
client.SetAccountSuccessHandler(func(ctx context.Context) {
|
|
91
|
+
markedSuccess = true
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
attempts := 0
|
|
95
|
+
doer := &inspectDoer{fn: func(req *http.Request) (*http.Response, error) {
|
|
96
|
+
attempts++
|
|
97
|
+
switch attempts {
|
|
98
|
+
case 1:
|
|
99
|
+
if got := req.Header.Get("ChatGPT-Account-Id"); got != "acc-1" {
|
|
100
|
+
return nil, fmt.Errorf("expected first account header acc-1, got %s", got)
|
|
101
|
+
}
|
|
102
|
+
return response(http.StatusTooManyRequests, `{"error":"rate"}`), nil
|
|
103
|
+
case 2:
|
|
104
|
+
if got := req.Header.Get("ChatGPT-Account-Id"); got != "acc-2" {
|
|
105
|
+
return nil, fmt.Errorf("expected second account header acc-2, got %s", got)
|
|
106
|
+
}
|
|
107
|
+
return response(http.StatusOK, `{"output_text":"done","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`), nil
|
|
108
|
+
default:
|
|
109
|
+
return nil, fmt.Errorf("unexpected attempt %d", attempts)
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
|
|
113
|
+
out, err := client.chatWithDoer(context.Background(), providers.ChatRequest{Role: providers.RoleCoder}, doer)
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatalf("chat should succeed after failover: %v", err)
|
|
116
|
+
}
|
|
117
|
+
if out.ProviderMetadata["account_id"] != "acc-2" {
|
|
118
|
+
t.Fatalf("expected account_id metadata acc-2, got %q", out.ProviderMetadata["account_id"])
|
|
119
|
+
}
|
|
120
|
+
if !markedSuccess {
|
|
121
|
+
t.Fatalf("expected account success handler to be called")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
73
125
|
func TestValidateMissingKey(t *testing.T) {
|
|
74
126
|
_ = os.Unsetenv("OPENAI_API_KEY")
|
|
75
127
|
client := New(config.OpenAIProviderConfig{APIKeyEnv: "OPENAI_API_KEY", BaseURL: "https://example.test/v1"})
|
|
@@ -62,3 +62,45 @@ func TestReadStateConnectedWithStoredAPIKey(t *testing.T) {
|
|
|
62
62
|
t.Fatalf("unexpected connected providers: %+v", state.Connected)
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
func TestReadStateUsesActiveOAuthCredential(t *testing.T) {
|
|
67
|
+
repoRoot := t.TempDir()
|
|
68
|
+
cfg := config.DefaultConfig()
|
|
69
|
+
cfg.Provider.Default = "openai"
|
|
70
|
+
cfg.Provider.OpenAI.AuthMode = "account"
|
|
71
|
+
if err := config.Save(repoRoot, cfg); err != nil {
|
|
72
|
+
t.Fatalf("save config: %v", err)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if err := auth.Set(repoRoot, "openai", auth.Credential{Type: "oauth", AccessToken: testAccountToken("acc-1"), RefreshToken: "refresh-1", AccountID: "acc-1", Email: "one@example.com"}); err != nil {
|
|
76
|
+
t.Fatalf("save first oauth: %v", err)
|
|
77
|
+
}
|
|
78
|
+
if err := auth.Set(repoRoot, "openai", auth.Credential{Type: "oauth", AccessToken: testAccountToken("acc-2"), RefreshToken: "refresh-2", AccountID: "acc-2", Email: "two@example.com"}); err != nil {
|
|
79
|
+
t.Fatalf("save second oauth: %v", err)
|
|
80
|
+
}
|
|
81
|
+
if err := auth.SetActive(repoRoot, "openai", "acc-1"); err != nil {
|
|
82
|
+
t.Fatalf("set active oauth: %v", err)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
state, err := ReadState(repoRoot)
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatalf("read state: %v", err)
|
|
88
|
+
}
|
|
89
|
+
if !state.OpenAI.Connected {
|
|
90
|
+
t.Fatalf("expected connected openai state")
|
|
91
|
+
}
|
|
92
|
+
if state.OpenAI.Source != "local" {
|
|
93
|
+
t.Fatalf("expected local source, got: %s", state.OpenAI.Source)
|
|
94
|
+
}
|
|
95
|
+
active, err := auth.Get(repoRoot, "openai")
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatalf("get active oauth: %v", err)
|
|
98
|
+
}
|
|
99
|
+
if active == nil || active.ID != "acc-1" {
|
|
100
|
+
t.Fatalf("expected acc-1 active, got %#v", active)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func testAccountToken(accountID string) string {
|
|
105
|
+
return "eyJhbGciOiJub25lIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoi" + accountID + "In19.sig"
|
|
106
|
+
}
|