teamclaude-cloud 1.8.1 → 1.9.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/src/server.js CHANGED
@@ -484,7 +484,7 @@ export function createProxyServer(accountManager, config, hooks = {}) {
484
484
  // tried429/tried5xx/authRetried hold account OBJECTS (not indexes), and
485
485
  // `held` is the acquired account OBJECT — both stable across a concurrent
486
486
  // removeAccount() re-index, so a release/exclude can't target the wrong account.
487
- const ctx = { account: null, status: null, authRetried: new Set(), tried429: new Set(), tried5xx: new Set(), overloadRetries: 0, held: null, queueTimeoutMs, abortSignal: null, affinityKey: sessionAffinity ? req.socket : null, sawModelWeekly: false };
487
+ const ctx = { account: null, status: null, authRetried: new Set(), tried429: new Set(), tried5xx: new Set(), triedError: new Set(), overloadRetries: 0, held: null, queueTimeoutMs, abortSignal: null, affinityKey: sessionAffinity ? req.socket : null, sawModelWeekly: false };
488
488
  try {
489
489
  // Buffer request body (needed for retry on 429), bounded by maxBodyBytes.
490
490
  const bodyChunks = [];
@@ -776,8 +776,8 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
776
776
  // Select account. On a failover retry (a prior account 429'd / 5xx'd for this
777
777
  // request) ctx.tried* is non-empty → pick a different account, skipping the
778
778
  // ones already tried.
779
- const excludeForSelect = (ctx.tried429.size || ctx.tried5xx.size)
780
- ? new Set([...ctx.tried429, ...ctx.tried5xx])
779
+ const excludeForSelect = (ctx.tried429.size || ctx.tried5xx.size || ctx.triedError.size)
780
+ ? new Set([...ctx.tried429, ...ctx.tried5xx, ...ctx.triedError])
781
781
  : null;
782
782
 
783
783
  // Reserve a per-account concurrency slot. On a 401 same-account refresh-retry
@@ -805,19 +805,28 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
805
805
 
806
806
  if (!account) {
807
807
  ctx.account = '(none available)';
808
- // If every account is in auth-error state, this is an authentication
809
- // problem (revoked/expired tokens needing re-login), not a rate limit
810
- // return 401 so the client surfaces it instead of pointlessly backing off.
808
+ // Every account is in error state. Classify by the FAILURE CLASS recorded when each errored:
809
+ // ALL transient (cloud refresh 5xx/network the recovery timer heals them) retryable 503,
810
+ // not a 401 (a 401 "re-login required" would trigger needless client re-auth/teardown for a
811
+ // temporary failure the client can't fix — the proxy's accounts).
812
+ // • ANY terminal failure (a 4xx invalid_grant/revoked key, or a non-cloud local auth failure
813
+ // the recovery timer never touches) → 401 so it isn't silently retried forever.
811
814
  const accts = accountManager.accounts;
812
815
  if (accts.length > 0 && accts.every(a => a.status === 'error')) {
816
+ if (accts.every(a => a.errorTransient === true)) {
817
+ ctx.status = 503;
818
+ res.writeHead(503, { 'Content-Type': 'application/json', 'retry-after': '30' });
819
+ res.end(JSON.stringify({
820
+ type: 'error',
821
+ error: { type: 'overloaded_error', message: `All ${accts.length} cloud-managed accounts are temporarily unavailable (token refresh failing); recovering — retry shortly.` },
822
+ }));
823
+ return;
824
+ }
813
825
  ctx.status = 401;
814
826
  res.writeHead(401, { 'Content-Type': 'application/json' });
815
827
  res.end(JSON.stringify({
816
828
  type: 'error',
817
- error: {
818
- type: 'authentication_error',
819
- message: `All ${accts.length} accounts failed authentication. Re-login required.`,
820
- },
829
+ error: { type: 'authentication_error', message: `All ${accts.length} accounts failed authentication. Re-login required.` },
821
830
  }));
822
831
  return;
823
832
  }
@@ -871,9 +880,71 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
871
880
  return;
872
881
  }
873
882
 
874
- if (account.status === 'error' && retryCount < maxRetries) {
875
- releaseHeld(); // failing over to a different account
876
- return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
883
+ if (account.status === 'error') {
884
+ releaseHeld(); // this account can't serve — fail over, or give up locally
885
+ if (res.destroyed) return; // client gone outer finally frees the slot
886
+ // Fail over while ANY untried account is still usable — do NOT cap on the shared
887
+ // `retryCount` budget here: 401 same-account refresh-retries and 429/5xx failovers may
888
+ // have already elevated it, which would wrongly 429 the client while a HEALTHY account
889
+ // (e.g. the user's 4th of 11) sits untried. Record this errored account so `anyUsable`
890
+ // shrinks each pass (error accounts are already excluded from selection by status too),
891
+ // guaranteeing termination once no usable account remains.
892
+ ctx.triedError.add(account);
893
+ // Termination is guaranteed by the EXCLUSION SET, not anyUsable's internal availability
894
+ // logic: triedError only grows and holds distinct account OBJECTS, so once every account
895
+ // is in excludeTried, `!excludeTried.has(a)` is false for all → anyUsable returns false
896
+ // regardless of _isAvailable/_hasCapacity. So a future change to anyUsable can't create an
897
+ // unbounded loop (bounded by accounts.length distinct objects).
898
+ const excludeTried = new Set([...ctx.tried429, ...ctx.tried5xx, ...ctx.triedError]);
899
+ // Accept anyCapped too (like the 429/5xx failover paths): a healthy untried account at
900
+ // its concurrency cap will free shortly, so fail over and let acquireAccount's bounded
901
+ // overflow queue wait for it rather than immediately 429'ing under load. Still bounded —
902
+ // a capped account either frees and serves, errors (→ triedError), or the queue times out.
903
+ if (accountManager.anyUsable(excludeTried) || accountManager.anyCapped(excludeTried)) {
904
+ return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
905
+ }
906
+ // No untried usable account remains — the selected account's token is dead (e.g. a
907
+ // cloud-managed account whose central refresh failed; single-authority forbids a local
908
+ // rotation, so ensureTokenFresh left it status='error'). Do NOT forward its EXPIRED
909
+ // bearer token (that returns a confusing upstream 401 and defeats the immediate-
910
+ // unavailability contract). Classify the terminal EXACTLY like the no-account path above so
911
+ // identical "all credentials failed" states behave identically to the client: if EVERY
912
+ // account is in auth-error state it's an authentication problem (re-login) → 401; otherwise
913
+ // it's transient unavailability → local 429 so the client backs off while the recovery timer
914
+ // re-probes the cloud and heals the account.
915
+ if (!res.headersSent) {
916
+ const accts = accountManager.accounts;
917
+ if (accts.length > 0 && accts.every(a => a.status === 'error')) {
918
+ // Same classification as the no-account terminal above (consistent across both
919
+ // "all credentials failed" paths): all-transient → healable → retryable 503; any
920
+ // terminal failure (4xx invalid_grant / non-cloud auth) → 401.
921
+ if (accts.every(a => a.errorTransient === true)) {
922
+ ctx.status = 503;
923
+ res.writeHead(503, { 'Content-Type': 'application/json', 'retry-after': '30' });
924
+ res.end(JSON.stringify({
925
+ type: 'error',
926
+ error: { type: 'overloaded_error', message: `All ${accts.length} cloud-managed accounts are temporarily unavailable (token refresh failing); recovering — retry shortly.` },
927
+ }));
928
+ return;
929
+ }
930
+ ctx.status = 401;
931
+ res.writeHead(401, { 'Content-Type': 'application/json' });
932
+ res.end(JSON.stringify({
933
+ type: 'error',
934
+ error: { type: 'authentication_error', message: `All ${accts.length} accounts failed authentication. Re-login required.` },
935
+ }));
936
+ return;
937
+ }
938
+ ctx.status = 429;
939
+ res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '10' });
940
+ res.end(JSON.stringify({
941
+ type: 'error',
942
+ error: { type: 'rate_limit_error', message: 'No account with a valid token is available right now; please retry shortly.' },
943
+ }));
944
+ } else {
945
+ ctx.status = ctx.status || 429;
946
+ }
947
+ return;
877
948
  }
878
949
 
879
950
  // Build upstream request headers
package/src/tui.js CHANGED
@@ -479,6 +479,13 @@ export class TUI {
479
479
  const prev = this.config.accounts[idx];
480
480
  if (prev.enabled !== undefined) entry.enabled = prev.enabled;
481
481
  if (prev.priority !== undefined) entry.priority = prev.priority;
482
+ // CRITICAL: preserve cloud provenance/display too — a re-import replaces the TOKEN, not
483
+ // the account's cloud-sharing status. Dropping cloudSources/cloudPushed would make a
484
+ // cloud-managed (shared) account look LOCAL → it would locally rotate its now-shared
485
+ // refresh token and poison the chain for every other PC (single-authority invariant).
486
+ if (Array.isArray(prev.cloudSources) && prev.cloudSources.length) entry.cloudSources = prev.cloudSources;
487
+ if (prev.cloudPushed) entry.cloudPushed = prev.cloudPushed;
488
+ if (prev.cloudPrivate) entry.cloudPrivate = prev.cloudPrivate;
482
489
  this.config.accounts[idx] = entry;
483
490
  // Update the running account manager entry, matched by IDENTITY (the
484
491
  // previous entry's UUID first, then name) — NOT the config index `idx`,