@timo972/cc-router 0.12.4 → 0.13.0-rc.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.
@@ -1,5 +1,6 @@
1
1
  import { UsageRefresher } from "../../proxy/usage-refresher.js";
2
2
  import { fetchAnthropicUsage } from "./usage.js";
3
+ import { canReadProfile } from "./scopes.js";
3
4
  import { httpOutcome, recordRuntimeError, recordSafeLog, recordUpstreamStatus, withTelemetrySpan, } from "../../telemetry/facade.js";
4
5
  /**
5
6
  * The Anthropic instantiation of the shared usage scheduler (see
@@ -12,39 +13,45 @@ export class AnthropicUsageRefresher extends UsageRefresher {
12
13
  const now = options.now ?? Date.now;
13
14
  const fetchUsage = options.fetchUsage ?? fetchAnthropicUsage;
14
15
  super(pool, {
15
- fetchUsage: account => withTelemetrySpan("provider.usage_refresh", { provider: "anthropic" }, async (span) => {
16
- let result;
17
- try {
18
- result = await fetchUsage(account);
19
- }
20
- catch (error) {
21
- recordRuntimeError(error, { operation: "provider.usage_refresh", provider: "anthropic" });
22
- throw error;
23
- }
24
- if (!result.ok) {
25
- if (result.status !== undefined) {
26
- recordUpstreamStatus("provider.usage_refresh", "anthropic", result.status);
27
- span.fail({ httpStatusCode: result.status, outcome: httpOutcome(result.status) });
16
+ // The OAuth usage endpoint needs `user:profile`. An inference-only
17
+ // credential (`claude setup-token`) can only ever be answered with a
18
+ // 403, so the request is never sent: the account lands on the same
19
+ // "unavailable" usage state without spending an upstream call.
20
+ fetchUsage: account => !canReadProfile(account.tokens.scopes)
21
+ ? Promise.resolve({ ok: false, reason: "http", status: 403 })
22
+ : withTelemetrySpan("provider.usage_refresh", { provider: "anthropic" }, async (span) => {
23
+ let result;
24
+ try {
25
+ result = await fetchUsage(account);
28
26
  }
29
- else {
30
- // fetchAnthropicUsage resolves transport failures instead of
31
- // throwing, so the unsampled failure log is emitted here.
32
- const reason = result.reason === "timeout" ? "timeout"
33
- : result.reason === "network" ? "network_failure"
34
- : "other";
35
- const outcome = reason === "timeout" ? "timeout" : "upstream_error";
36
- recordSafeLog({
37
- operation: "provider.usage_refresh",
38
- provider: "anthropic",
39
- severity: "error",
40
- reason,
41
- outcome,
42
- });
43
- span.fail({ outcome });
27
+ catch (error) {
28
+ recordRuntimeError(error, { operation: "provider.usage_refresh", provider: "anthropic" });
29
+ throw error;
44
30
  }
45
- }
46
- return result;
47
- }),
31
+ if (!result.ok) {
32
+ if (result.status !== undefined) {
33
+ recordUpstreamStatus("provider.usage_refresh", "anthropic", result.status);
34
+ span.fail({ httpStatusCode: result.status, outcome: httpOutcome(result.status) });
35
+ }
36
+ else {
37
+ // fetchAnthropicUsage resolves transport failures instead of
38
+ // throwing, so the unsampled failure log is emitted here.
39
+ const reason = result.reason === "timeout" ? "timeout"
40
+ : result.reason === "network" ? "network_failure"
41
+ : "other";
42
+ const outcome = reason === "timeout" ? "timeout" : "upstream_error";
43
+ recordSafeLog({
44
+ operation: "provider.usage_refresh",
45
+ provider: "anthropic",
46
+ severity: "error",
47
+ reason,
48
+ outcome,
49
+ });
50
+ span.fail({ outcome });
51
+ }
52
+ }
53
+ return result;
54
+ }),
48
55
  cancelledResult: () => ({ ok: false, reason: "network" }),
49
56
  applyResult: (account, result) => {
50
57
  if (result.ok) {
@@ -1,9 +1,20 @@
1
1
  import { createOpenAIAccountRecord } from "./account-record.js";
2
+ import { openInBrowser } from "../../utils/browser.js";
2
3
  import { SetupDiagnosticError, classifyHttpSetupFailure, classifyNetworkSetupFailure, } from "../../telemetry/setup-diagnostics.js";
3
4
  const DEFAULT_ISSUER = "https://auth.openai.com";
4
5
  export const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
5
6
  const DEFAULT_SCOPE = "openid profile email offline_access";
6
7
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
+ /**
9
+ * The device page accepts the code (and the account to sign in as) in the query
10
+ * string, so the person only has to confirm rather than retype anything.
11
+ */
12
+ export function buildOpenAIDeviceVerificationUrl(issuer, userCode, loginHint) {
13
+ const params = new URLSearchParams({ user_code: userCode });
14
+ if (loginHint)
15
+ params.set("login_hint", loginHint);
16
+ return `${issuer.replace(/\/+$/, "")}/codex/device?${params.toString()}`;
17
+ }
7
18
  function issuerOf(opts) {
8
19
  return (opts.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
9
20
  }
@@ -82,7 +93,7 @@ export async function requestOpenAIDeviceCode(opts = {}) {
82
93
  });
83
94
  }
84
95
  return {
85
- verificationUrl: `${issuer}/codex/device`,
96
+ verificationUrl: buildOpenAIDeviceVerificationUrl(issuer, userCode, opts.loginHint),
86
97
  userCode,
87
98
  deviceAuthId: body.device_auth_id,
88
99
  intervalSeconds: Number(body.interval ?? 5),
@@ -162,6 +173,14 @@ export async function loginOpenAIWithDeviceCode(opts) {
162
173
  const deviceCode = await requestOpenAIDeviceCode(opts);
163
174
  opts.onStageCompleted?.("device_code_request");
164
175
  opts.onDeviceCode?.(deviceCode);
176
+ // Best effort, and only after the URL and code have been printed: a failed
177
+ // open must never abort a sign-in the person can still finish by hand. The
178
+ // catch covers an injected opener too, since only the default one promises
179
+ // not to throw.
180
+ try {
181
+ await (opts.openBrowser ?? openInBrowser)(deviceCode.verificationUrl);
182
+ }
183
+ catch { /* the URL and code are already on screen */ }
165
184
  const tokens = await exchangeOpenAIDeviceCodeForTokens({ ...opts, deviceCode });
166
185
  return createOpenAIAccountRecord({
167
186
  id: opts.accountId,
@@ -1,4 +1,5 @@
1
1
  import { createXaiAccountRecord, XAI_DEFAULT_SCOPES } from "./account-record.js";
2
+ import { openInBrowser } from "../../utils/browser.js";
2
3
  const DEFAULT_ISSUER = "https://auth.x.ai";
3
4
  const DEFAULT_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
4
5
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
@@ -106,6 +107,14 @@ export async function exchangeXaiDeviceCodeForTokens(opts) {
106
107
  export async function loginXaiWithDeviceCode(opts) {
107
108
  const deviceCode = await requestXaiDeviceCode(opts);
108
109
  opts.onDeviceCode?.(deviceCode);
110
+ // Best effort, and only after the URL and code have been printed: a failed
111
+ // open must never abort a sign-in the person can still finish by hand. The
112
+ // catch covers an injected opener too, since only the default one promises
113
+ // not to throw.
114
+ try {
115
+ await (opts.openBrowser ?? openInBrowser)(deviceCode.verificationUrl);
116
+ }
117
+ catch { /* the URL and code are already on screen */ }
109
118
  const tokens = await exchangeXaiDeviceCodeForTokens({ ...opts, deviceCode });
110
119
  return createXaiAccountRecord({
111
120
  id: opts.accountId,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { fetchAccountInfo } from "../providers/account-info-fetch.js";
3
3
  import { sanitizeAccountInfo } from "../providers/account-info.js";
4
+ import { canReadProfile } from "../providers/anthropic/scopes.js";
4
5
  const TTL_MS = 5 * 60_000;
5
6
  const RETRY_MS = 60_000;
6
7
  const key = (account) => `${account.provider}:${account.id}`;
@@ -11,6 +12,10 @@ export class AccountInfoCache {
11
12
  accounts;
12
13
  entries = new Map();
13
14
  inFlight;
15
+ /** One upstream request per account at a time: a dashboard `refreshOne`
16
+ * landing while the scheduled pass is on the same account joins it, so
17
+ * no second request is sent and no older response can land last. */
18
+ fetching = new Map();
14
19
  pendingForced;
15
20
  controller = new AbortController();
16
21
  timer;
@@ -73,7 +78,9 @@ export class AccountInfoCache {
73
78
  this.entries.set(id, entry);
74
79
  }
75
80
  const ttl = entry.info?.fetchStatus === "fresh" ? TTL_MS : RETRY_MS;
76
- return account.enabled !== false && account.expiresAt > this.now()
81
+ // An inference-only Claude credential cannot read the profile endpoint;
82
+ // fetching would only ever produce a 403. Other providers are never gated.
83
+ return this.canFetch(account)
77
84
  && (force || entry.attemptedAt === undefined || this.now() - entry.attemptedAt >= ttl);
78
85
  });
79
86
  const worker = async () => {
@@ -81,29 +88,71 @@ export class AccountInfoCache {
81
88
  const account = queue.shift();
82
89
  if (!account)
83
90
  break;
84
- // Re-check presence/credentials before network I/O after time spent queued.
85
- const current = this.accounts().find(row => key(row) === key(account));
86
- if (!current || current.enabled === false || current.expiresAt <= this.now() || fingerprint(current) !== fingerprint(account))
87
- continue;
88
- const entry = this.entries.get(key(account));
89
- entry.attemptedAt = this.now();
90
- let info;
91
- try {
92
- info = await this.fetchInfo(account, { signal: this.controller.signal, now: this.now });
93
- }
94
- catch { /* best effort */ }
95
- const latest = this.accounts().find(row => key(row) === key(account));
96
- if (this.controller.signal.aborted || !latest || fingerprint(latest) !== entry.fingerprint)
97
- continue;
98
- const safe = sanitizeAccountInfo(info);
99
- if (safe && (safe.fetchStatus === "fresh" || !entry.info))
100
- entry.info = safe;
101
- else if (entry.info)
102
- entry.info = { ...entry.info, fetchStatus: "stale" };
91
+ await this.fetchOne(account);
103
92
  }
104
93
  };
105
94
  await Promise.all([worker(), worker()]);
106
95
  }
96
+ /**
97
+ * Refresh one account's metadata now, leaving every other account alone.
98
+ * The per-account refresh endpoint uses this: a whole-cache `refresh(true)`
99
+ * from one dashboard keypress would hit every provider's profile endpoint
100
+ * for the entire fleet. Unknown, disabled, expired and inference-only
101
+ * accounts are skipped exactly as the scheduled pass skips them.
102
+ */
103
+ async refreshOne(target) {
104
+ if (this.controller.signal.aborted)
105
+ return;
106
+ const account = this.accounts().find(row => row.id === target.id && row.provider === target.provider);
107
+ if (!account || !this.canFetch(account))
108
+ return;
109
+ const id = key(account);
110
+ const digest = fingerprint(account);
111
+ if (this.entries.get(id)?.fingerprint !== digest)
112
+ this.entries.set(id, { fingerprint: digest });
113
+ await this.fetchOne(account);
114
+ }
115
+ /** Eligibility shared by the scheduled pass and `refreshOne`. */
116
+ canFetch(account) {
117
+ // An inference-only Claude credential cannot read the profile endpoint;
118
+ // fetching would only ever produce a 403. Other providers are never gated.
119
+ return account.enabled !== false && account.expiresAt > this.now()
120
+ && (account.provider !== "anthropic_subscription" || canReadProfile(account.scopes));
121
+ }
122
+ fetchOne(account) {
123
+ const id = key(account);
124
+ const active = this.fetching.get(id);
125
+ if (active)
126
+ return active;
127
+ const operation = this.fetchOneUnguarded(account)
128
+ .finally(() => { if (this.fetching.get(id) === operation)
129
+ this.fetching.delete(id); });
130
+ this.fetching.set(id, operation);
131
+ return operation;
132
+ }
133
+ async fetchOneUnguarded(account) {
134
+ // Re-check presence/credentials before network I/O after time spent queued.
135
+ const current = this.accounts().find(row => key(row) === key(account));
136
+ if (!current || current.enabled === false || current.expiresAt <= this.now() || fingerprint(current) !== fingerprint(account))
137
+ return;
138
+ const entry = this.entries.get(key(account));
139
+ if (!entry)
140
+ return;
141
+ entry.attemptedAt = this.now();
142
+ let info;
143
+ try {
144
+ info = await this.fetchInfo(account, { signal: this.controller.signal, now: this.now });
145
+ }
146
+ catch { /* best effort */ }
147
+ const latest = this.accounts().find(row => key(row) === key(account));
148
+ if (this.controller.signal.aborted || !latest || fingerprint(latest) !== entry.fingerprint)
149
+ return;
150
+ const safe = sanitizeAccountInfo(info);
151
+ if (safe && (safe.fetchStatus === "fresh" || !entry.info))
152
+ entry.info = safe;
153
+ else if (entry.info)
154
+ entry.info = { ...entry.info, fetchStatus: "stale" };
155
+ }
107
156
  stop() {
108
157
  if (this.timer)
109
158
  clearInterval(this.timer);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Field validation for POST /cc-router/accounts, pulled out of the route so
3
+ * the one behavioural exception is testable on its own: an Anthropic record
4
+ * may omit `refreshToken` (a `claude setup-token` credential has none), every
5
+ * other provider must send one.
6
+ */
7
+ export function validateAccountPostBody(raw) {
8
+ const body = (raw && typeof raw === "object" ? raw : {});
9
+ const isAnthropic = body.provider === undefined || body.provider === "anthropic_subscription";
10
+ const required = isAnthropic
11
+ ? ["id", "accessToken", "expiresAt"]
12
+ : ["id", "accessToken", "refreshToken", "expiresAt"];
13
+ for (const k of required) {
14
+ if (body[k] === undefined || body[k] === null || body[k] === "") {
15
+ return { ok: false, status: 400, error: `Missing required field: ${k}` };
16
+ }
17
+ }
18
+ if (typeof body.id !== "string" || typeof body.accessToken !== "string" || typeof body.expiresAt !== "number"
19
+ || (body.refreshToken !== undefined && typeof body.refreshToken !== "string")) {
20
+ return { ok: false, status: 400, error: "Invalid field types on account record" };
21
+ }
22
+ return { ok: true, body: { ...body, replace: body.replace === true } };
23
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One account's worth of `POST /cc-router/refresh`: token if due, usage, then
3
+ * identity. Step failures are reported, never thrown — only an unexpected
4
+ * throw escapes so the route can answer 500. Single-flighted per id so a
5
+ * second press joins the running pass instead of stacking a second fetch.
6
+ */
7
+ export function createAccountRefreshRunner(hooks) {
8
+ const now = hooks.now ?? Date.now;
9
+ const inFlight = new Map();
10
+ const attempt = async (fn, fallback) => {
11
+ try {
12
+ return await fn();
13
+ }
14
+ catch {
15
+ return fallback;
16
+ }
17
+ };
18
+ return id => {
19
+ const existing = inFlight.get(id);
20
+ if (existing)
21
+ return existing;
22
+ let run;
23
+ run = (async () => {
24
+ const started = now();
25
+ const anthropic = hooks.findAnthropic(id);
26
+ const openai = anthropic ? undefined : hooks.findOpenAI(id);
27
+ if (!anthropic && !openai)
28
+ return null;
29
+ let tokenRefreshed = null;
30
+ let usageRefreshed = false;
31
+ if (anthropic) {
32
+ if (hooks.anthropicTokenDue(anthropic)) {
33
+ tokenRefreshed = await attempt(() => hooks.refreshAnthropicToken(anthropic), false);
34
+ }
35
+ usageRefreshed = (await attempt(() => hooks.refreshAnthropicUsage(anthropic), { ok: false })).ok;
36
+ }
37
+ else if (openai) {
38
+ if (hooks.openAITokenDue(openai)) {
39
+ tokenRefreshed = await attempt(() => hooks.refreshOpenAIToken(openai), false);
40
+ }
41
+ usageRefreshed = (await attempt(() => hooks.refreshOpenAIUsage(openai), { ok: false })).ok;
42
+ }
43
+ await attempt(() => hooks.refreshIdentity({
44
+ id,
45
+ provider: anthropic ? "anthropic_subscription" : "openai_subscription",
46
+ }), undefined);
47
+ const result = { id, tokenRefreshed, usageRefreshed, durationMs: Math.max(0, now() - started) };
48
+ // A reporting failure must not turn a completed refresh into a 500.
49
+ try {
50
+ hooks.onComplete?.(result);
51
+ }
52
+ catch { /* best effort */ }
53
+ return result;
54
+ })().finally(() => { if (inFlight.get(id) === run)
55
+ inFlight.delete(id); });
56
+ inFlight.set(id, run);
57
+ return run;
58
+ };
59
+ }
@@ -1,3 +1,4 @@
1
+ import { withInheritedSettings } from "./types.js";
1
2
  import { reserveAccountForDeletion } from "./token-refresher.js";
2
3
  import { createOpenAIAccount } from "../providers/openai/account-state.js";
3
4
  /**
@@ -50,7 +51,9 @@ export async function replaceAnthropicAccountTransaction(options) {
50
51
  // old incarnation's in-flight count and cooldowns (the replacement must
51
52
  // not inherit a bench it never earned) and hands back a rollback that
52
53
  // restores every bit of it if the write below fails.
53
- const { added, rollback } = options.pool.replaceAccount(options.record);
54
+ // Credentials come from the sign-in; enabled and the caps stay the
55
+ // operator's unless the record sets them explicitly.
56
+ const { added, rollback } = options.pool.replaceAccount(withInheritedSettings(options.record, previous));
54
57
  try {
55
58
  options.persist(options.pool.getAll());
56
59
  }
@@ -78,19 +81,16 @@ export function replaceOpenAIAccountTransaction(options) {
78
81
  if (index < 0)
79
82
  throw new Error(`Account "${options.record.id}" not found`);
80
83
  const previous = options.accounts[index];
84
+ const record = withInheritedSettings(options.record, previous);
81
85
  const account = createOpenAIAccount({
82
- id: options.record.id,
86
+ id: record.id,
83
87
  provider: "openai_subscription",
84
- accessToken: options.record.accessToken,
85
- refreshToken: options.record.refreshToken,
86
- expiresAt: options.record.expiresAt,
87
- enabled: options.record.enabled !== false,
88
- ...(options.record.sessionLimitPercent !== undefined
89
- ? { sessionLimitPercent: options.record.sessionLimitPercent }
90
- : {}),
91
- ...(options.record.weeklyLimitPercent !== undefined
92
- ? { weeklyLimitPercent: options.record.weeklyLimitPercent }
93
- : {}),
88
+ accessToken: record.accessToken,
89
+ refreshToken: record.refreshToken,
90
+ expiresAt: record.expiresAt,
91
+ enabled: record.enabled !== false,
92
+ ...(record.sessionLimitPercent !== undefined ? { sessionLimitPercent: record.sessionLimitPercent } : {}),
93
+ ...(record.weeklyLimitPercent !== undefined ? { weeklyLimitPercent: record.weeklyLimitPercent } : {}),
94
94
  });
95
95
  // Splice in place so the replacement keeps the old account's position; the
96
96
  // array reference is the one the pool, router, and refresh loop all hold.
@@ -5,7 +5,8 @@ import { createProxyMiddleware } from "http-proxy-middleware";
5
5
  import { ServerResponse } from "http";
6
6
  import { timingSafeEqual } from "crypto";
7
7
  import { TokenPool } from "./token-pool.js";
8
- import { needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccounts, startRefreshLoop } from "./token-refresher.js";
8
+ import { expireTokenOnlyAccount, needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccounts, startRefreshLoop } from "./token-refresher.js";
9
+ import { createAccountRefreshRunner } from "./account-refresh.js";
9
10
  import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
10
11
  import { createRefreshAllRunner, describeRefreshAll, refreshAllAccounts } from "./pool-refresh.js";
11
12
  import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
@@ -18,9 +19,11 @@ import { mountAnthropicMessagesRoute, withOAuthBeta } from "./anthropic-messages
18
19
  import { PROXY_PORT, LITELLM_URL, ACCOUNTS_PATH } from "../config/paths.js";
19
20
  import { loadGrokHealthSnapshots } from "../providers/xai/overview.js";
20
21
  import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
22
+ import { isTokenOnly } from "./types.js";
21
23
  import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
24
+ import { validateAccountPostBody } from "./account-post-validation.js";
22
25
  import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
23
- import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshOpenAIAccountsOnce, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
26
+ import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, needsOpenAIRefresh, prepareOpenAIAccountForRequest, refreshOpenAIAccountsOnce, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
24
27
  import { createOpenAIAccount } from "../providers/openai/account-state.js";
25
28
  import { OpenAITokenPool } from "../providers/openai/token-pool.js";
26
29
  import { DEFAULT_CODEX_LIMIT_ID } from "../providers/openai/usage.js";
@@ -70,6 +73,7 @@ export function createOperationalStatus(opts) {
70
73
  accounts: "/cc-router/accounts",
71
74
  allowance: "/cc-router/allowance",
72
75
  refresh: "/cc-router/refresh",
76
+ accountRefresh: "/cc-router/accounts/:id/refresh",
73
77
  messages: "/v1/messages",
74
78
  responses: "/v1/responses",
75
79
  models: "/v1/models",
@@ -126,6 +130,7 @@ function publicAnthropicAccountView(a, metrics) {
126
130
  // of `authState === "quarantined"`.
127
131
  healthy: a.enabled !== false && a.healthy && a.authExpired !== true,
128
132
  ...(a.authExpired ? { authExpired: true } : {}),
133
+ ...(a.tokens.refreshToken ? {} : { tokenOnly: true }),
129
134
  busy: a.busy || metrics.coolingDown,
130
135
  cooldownUntilMs: metrics.cooldownUntilMs ?? 0,
131
136
  globalCooldownUntilMs: metrics.globalCooldownUntilMs ?? 0,
@@ -464,6 +469,7 @@ export async function startServer(opts = {}) {
464
469
  ...pool.getAll().map(account => ({
465
470
  id: account.id, provider: "anthropic_subscription",
466
471
  accessToken: account.tokens.accessToken, expiresAt: account.tokens.expiresAt, enabled: account.enabled,
472
+ scopes: account.tokens.scopes,
467
473
  })),
468
474
  ...openAIAccounts.map(account => ({
469
475
  id: account.id, provider: "openai_subscription",
@@ -659,6 +665,60 @@ export async function startServer(opts = {}) {
659
665
  captureReset: account => openAIPool.captureUsageReset(account),
660
666
  refresh: account => openAIUsageRefresher.refreshAfterCurrent(account),
661
667
  }));
668
+ // ─── Per-account refresh (authenticated) ──────────────────────────────────
669
+ // The whole-pool reload above, narrowed to one row: the operator who just
670
+ // re-authenticated a single account should not have to pay for every other
671
+ // account's upstream calls to see the result.
672
+ const runAccountRefresh = createAccountRefreshRunner({
673
+ findAnthropic: id => pool.findById(id),
674
+ findOpenAI: id => openAIAccounts.find(account => account.id === id),
675
+ // A long-lived `claude setup-token` credential has nothing to POST, so
676
+ // `needsRefresh` excludes it. Past its expiry it still needs the flip to
677
+ // `authExpired`, which is what the refresh step below does for it — and
678
+ // it stays "due" afterwards so every press reports the same unrefreshable
679
+ // token rather than a first `false` and a silent `null` after that.
680
+ anthropicTokenDue: account => needsRefresh(account)
681
+ || (isTokenOnly(account.tokens) && account.tokens.expiresAt <= Date.now()),
682
+ refreshAnthropicToken: async (account) => {
683
+ if (isTokenOnly(account.tokens)) {
684
+ // Nothing to refresh — mark it re-auth required once and persist, so
685
+ // the dashboard sees the same terminal state a restart would derive.
686
+ if (expireTokenOnlyAccount(account))
687
+ persistAnthropicAccounts(pool.getAll());
688
+ return false;
689
+ }
690
+ return refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts });
691
+ },
692
+ refreshAnthropicUsage: account => usageRefresher.refreshNow(account),
693
+ openAITokenDue: account => needsOpenAIRefresh(account),
694
+ refreshOpenAIToken: account => refreshAndPersistOpenAIAccount(account, openAIAccounts, persistOpenAIAccounts),
695
+ refreshOpenAIUsage: account => openAIUsageRefresher.refreshNow(account),
696
+ refreshIdentity: target => accountInfoCache.refreshOne(target),
697
+ // Logged from here, not from the route: concurrent requests for one id
698
+ // share a single pass, and a per-request write would record it twice.
699
+ onComplete: result => stats.addLog({
700
+ ts: Date.now(), accountId: result.id, model: "-", type: "refresh",
701
+ details: `manual refresh ${result.id} — ${result.usageRefreshed ? "usage fresh" : "usage fetch failed"}${result.tokenRefreshed === false ? ", token refresh failed" : ""}`,
702
+ }),
703
+ });
704
+ accountsRouter.post("/:id/refresh", async (req, res) => {
705
+ const id = req.params.id;
706
+ let result;
707
+ try {
708
+ result = await runAccountRefresh(id);
709
+ }
710
+ catch (err) {
711
+ const message = err instanceof Error ? err.message : String(err);
712
+ logError("refresh", 0, `manual refresh of ${id} failed: ${message}`);
713
+ res.status(500).json({ error: `Refresh failed: ${message}` });
714
+ return;
715
+ }
716
+ if (!result) {
717
+ res.status(404).json({ error: `Account "${id}" not found` });
718
+ return;
719
+ }
720
+ res.json({ refresh: result });
721
+ });
662
722
  // Shape returned to clients — NEVER includes access/refresh tokens.
663
723
  accountsRouter.get("/", (_req, res) => {
664
724
  const resolveRoutingMetrics = createRoutingMetricsResolver();
@@ -905,24 +965,16 @@ export async function startServer(opts = {}) {
905
965
  });
906
966
  });
907
967
  accountsRouter.post("/", async (req, res) => {
908
- const body = (req.body ?? {});
909
968
  // Opt-in upsert. Re-authenticating an existing id is the only recovery for
910
969
  // a terminally rejected refresh token, and refusing it here discarded the
911
970
  // OAuth login the operator had just completed. It stays opt-in so an
912
971
  // accidental id collision from any other API client still gets its 409.
913
- const wantsReplace = req.body?.replace === true;
914
- const required = ["id", "accessToken", "refreshToken", "expiresAt"];
915
- for (const k of required) {
916
- if (body[k] === undefined || body[k] === null || body[k] === "") {
917
- res.status(400).json({ error: `Missing required field: ${k}` });
918
- return;
919
- }
920
- }
921
- if (typeof body.id !== "string" || typeof body.accessToken !== "string" ||
922
- typeof body.refreshToken !== "string" || typeof body.expiresAt !== "number") {
923
- res.status(400).json({ error: "Invalid field types on account record" });
972
+ const validated = validateAccountPostBody(req.body);
973
+ if (!validated.ok) {
974
+ res.status(validated.status).json({ error: validated.error });
924
975
  return;
925
976
  }
977
+ const { replace: wantsReplace, ...body } = validated.body;
926
978
  // Same cap validation the PATCH endpoint applies, so the two writers of
927
979
  // these fields agree instead of POST silently clamping a bad value to 100.
928
980
  const capValidation = validateAccountPatchBody({
@@ -1,3 +1,4 @@
1
+ import { isTokenOnly } from "./types.js";
1
2
  import { writeAnthropicAccountsPreservingOtherProviders, serialize } from "../config/manager.js";
2
3
  import { logRefresh } from "./logger.js";
3
4
  import { stats } from "./stats.js";
@@ -56,11 +57,33 @@ export function needsRefresh(account) {
56
57
  // it out of the loop so it is not POSTed to the OAuth endpoint forever.
57
58
  if (account.authExpired)
58
59
  return false;
60
+ // A setup-token credential has nothing to POST; expiry is handled by
61
+ // expireTokenOnlyAccount instead of the refresh loop.
62
+ if (isTokenOnly(account.tokens))
63
+ return false;
59
64
  return ownedRefreshLocks.has(account) ||
60
65
  pendingDurability.has(account) ||
61
66
  (account.tokens.expiresAt - Date.now()) < REFRESH_BUFFER_MS;
62
67
  }
68
+ /**
69
+ * A long-lived token cannot be refreshed, so passing its expiry is the same
70
+ * terminal state as a rejected refresh token: only a new sign-in restores it.
71
+ * Returns true only on the tick that flips the account, so the caller can
72
+ * persist and log exactly once.
73
+ */
74
+ export function expireTokenOnlyAccount(account, now = Date.now()) {
75
+ if (!isTokenOnly(account.tokens) || account.authExpired || account.tokens.expiresAt > now)
76
+ return false;
77
+ account.authExpired = true;
78
+ account.healthy = false;
79
+ console.error(` Account ${account.id} needs re-authentication: its long-lived token has expired. Run: cc-router accounts reauth ${account.id}`);
80
+ return true;
81
+ }
63
82
  export async function refreshAccountToken(account) {
83
+ // Nothing to POST: a setup-token credential has no refresh token, so the
84
+ // OAuth token endpoint must never be contacted for it.
85
+ if (isTokenOnly(account.tokens))
86
+ return false;
64
87
  // A deletion reservation rejects every new caller, including callers that
65
88
  // would otherwise attach themselves to already-running raw refresh work.
66
89
  if (isReservedForDeletion(account))
@@ -162,10 +185,13 @@ export function refreshAccountIfCurrent(account, pool, options = {}) {
162
185
  return operation;
163
186
  }
164
187
  async function _doRefresh(account, span) {
188
+ const refreshToken = account.tokens.refreshToken;
189
+ if (!refreshToken)
190
+ return false;
165
191
  try {
166
192
  const body = new URLSearchParams({
167
193
  grant_type: "refresh_token",
168
- refresh_token: account.tokens.refreshToken,
194
+ refresh_token: refreshToken,
169
195
  client_id: CLAUDE_CODE_CLIENT_ID,
170
196
  });
171
197
  const res = await fetch(TOKEN_ENDPOINT, {
@@ -231,6 +257,15 @@ export async function refreshAccountsOnce(accounts, options = {}) {
231
257
  getAll: () => accounts,
232
258
  };
233
259
  for (const account of [...accounts]) {
260
+ if (expireTokenOnlyAccount(account)) {
261
+ try {
262
+ options.persist?.(accounts);
263
+ }
264
+ catch (error) {
265
+ (options.onError ?? console.error)(error);
266
+ }
267
+ continue;
268
+ }
234
269
  if (!needsRefresh(account))
235
270
  continue;
236
271
  try {
@@ -1,3 +1,22 @@
1
+ /** A credential with no refresh token can only be replaced, never refreshed. */
2
+ export function isTokenOnly(tokens) {
3
+ return !tokens.refreshToken;
4
+ }
5
+ /**
6
+ * Fill the operator-controlled settings a record omits from the account it
7
+ * replaces. Re-authentication hands over credentials only; without this a
8
+ * disabled account came back enabled and custom caps reset to 100.
9
+ */
10
+ export function withInheritedSettings(record, previous) {
11
+ return {
12
+ ...record,
13
+ ...(record.enabled === undefined && previous.enabled !== undefined ? { enabled: previous.enabled } : {}),
14
+ ...(record.sessionLimitPercent === undefined && previous.sessionLimitPercent !== undefined
15
+ ? { sessionLimitPercent: previous.sessionLimitPercent } : {}),
16
+ ...(record.weeklyLimitPercent === undefined && previous.weeklyLimitPercent !== undefined
17
+ ? { weeklyLimitPercent: previous.weeklyLimitPercent } : {}),
18
+ };
19
+ }
1
20
  export const DEFAULT_RATE_LIMITS = {
2
21
  status: "unknown",
3
22
  fiveHourUtil: 0,
@@ -17,6 +17,8 @@ export const SETUP_METHODS = [
17
17
  "claude_credentials_file",
18
18
  "manual_token",
19
19
  "device_oauth",
20
+ "claude_cli_login",
21
+ "claude_setup_token",
20
22
  ];
21
23
  export const SETUP_STAGES = [
22
24
  "attempt_start",