@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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/dist/cli/account-flows.js +422 -0
- package/dist/cli/cli-errors.js +37 -0
- package/dist/cli/cmd-accounts.js +215 -203
- package/dist/cli/cmd-setup.js +5 -186
- package/dist/cli/cmd-status.js +85 -24
- package/dist/cli/index.js +15 -3
- package/dist/config/manager.js +16 -7
- package/dist/providers/anthropic/claude-cli.js +104 -0
- package/dist/providers/anthropic/scopes.js +5 -0
- package/dist/providers/anthropic/usage-refresher.js +38 -31
- package/dist/providers/openai/device-oauth.js +20 -1
- package/dist/providers/xai/device-oauth.js +9 -0
- package/dist/proxy/account-info-cache.js +69 -20
- package/dist/proxy/account-post-validation.js +23 -0
- package/dist/proxy/account-refresh.js +59 -0
- package/dist/proxy/account-replace.js +12 -12
- package/dist/proxy/server.js +66 -14
- package/dist/proxy/token-refresher.js +36 -1
- package/dist/proxy/types.js +19 -0
- package/dist/telemetry/contracts.js +2 -0
- package/dist/ui/Dashboard.js +72 -6
- package/dist/ui/accountsApi.js +21 -0
- package/dist/utils/browser.js +36 -0
- package/dist/utils/expiry-input.js +21 -0
- package/dist/utils/token-extractor.js +9 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
86
|
+
id: record.id,
|
|
83
87
|
provider: "openai_subscription",
|
|
84
|
-
accessToken:
|
|
85
|
-
refreshToken:
|
|
86
|
-
expiresAt:
|
|
87
|
-
enabled:
|
|
88
|
-
...(
|
|
89
|
-
|
|
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.
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
|
914
|
-
|
|
915
|
-
|
|
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:
|
|
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 {
|
package/dist/proxy/types.js
CHANGED
|
@@ -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,
|