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.
@@ -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
- return auth.ResolveAccountAccessToken(o.repoRoot, "openai")
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 config.OpenAIProviderConfig
22
- httpClient *http.Client
23
- rand *rand.Rand
24
- resolveToken func(context.Context) (string, error)
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orch-code",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Local-first control plane for deterministic AI coding",
5
5
  "license": "MIT",
6
6
  "bin": {