@jameslovespancakes/pi-plus 1.0.11 → 1.0.13
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/README.md +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -2,43 +2,15 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { xxhash64 } from "./xxhash64.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Claude Code
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* READ THIS BEFORE CHANGING ANYTHING HERE
|
|
9
|
-
*
|
|
10
|
-
* These headers make a request indistinguishable from Anthropic's official
|
|
11
|
-
* Claude Code CLI. Without them Anthropic classifies the caller as a
|
|
12
|
-
* third-party app and answers:
|
|
13
|
-
*
|
|
14
|
-
* "Third-party apps now draw from your extra usage, not your plan limits."
|
|
15
|
-
*
|
|
16
|
-
* So the purpose of this file is to bill against plan limits rather than extra
|
|
17
|
-
* usage. That is plausibly contrary to Anthropic's intent as stated in that
|
|
18
|
-
* message, and the exposure falls on the account owner.
|
|
19
|
-
*
|
|
20
|
-
* It is isolated in one file, and referenced from exactly one place, so it can
|
|
21
|
-
* be deleted or replaced without touching the rest of the provider. Removing it
|
|
22
|
-
* does not break anything: requests keep working and bill to extra usage.
|
|
23
|
-
*
|
|
24
|
-
* Ported from @cortexkit/anthropic-auth-core during extraction.
|
|
25
|
-
* ---------------------------------------------------------------------------
|
|
5
|
+
* Emulates Claude Code so OAuth requests use plan limits instead of extra usage.
|
|
6
|
+
* This may conflict with Anthropic's stated intent; remove this identity from
|
|
7
|
+
* the provider to opt out. Adapted from @cortexkit/anthropic-auth-core.
|
|
26
8
|
*/
|
|
27
9
|
|
|
28
10
|
/** Pinned to the Claude Code release being imitated. */
|
|
29
11
|
export const CLAUDE_CODE_VERSION = "2.1.258";
|
|
30
12
|
|
|
31
|
-
/**
|
|
32
|
-
* Anti-tamper checksum, replicated from the official client.
|
|
33
|
-
*
|
|
34
|
-
* The real CLI signs the serialised request body with xxHash64 under a fixed
|
|
35
|
-
* seed and writes the low 20 bits into a `cch=` placeholder. Anthropic added
|
|
36
|
-
* this so a client cannot simply assert it is Claude Code in a header: it has
|
|
37
|
-
* to prove it by producing a value derived from the request itself.
|
|
38
|
-
*
|
|
39
|
-
* The salt and sample positions are constants in the official bundle, not
|
|
40
|
-
* derivable from anything, and they will change when the CLI is updated.
|
|
41
|
-
*/
|
|
13
|
+
/** Claude Code checksum constants. They can change between CLI releases. */
|
|
42
14
|
const CCH_SEED = 0x4d659218e32a3268n;
|
|
43
15
|
const CCH_SALT = "59cf53e54c78";
|
|
44
16
|
const CCH_POSITIONS = [4, 7, 20];
|
|
@@ -147,15 +119,7 @@ function hasFullAgentShape(body: any): boolean {
|
|
|
147
119
|
&& !!body?.thinking && typeof body.thinking === "object";
|
|
148
120
|
}
|
|
149
121
|
|
|
150
|
-
/**
|
|
151
|
-
* Paragraphs containing this anchor cannot sit in the top-level `system` array.
|
|
152
|
-
*
|
|
153
|
-
* Two lines of pi's documentation paragraph are each independently sufficient to
|
|
154
|
-
* make Anthropic answer 400 "Third-party apps now draw from your extra usage".
|
|
155
|
-
* The same text is accepted inside `messages`, so it is moved there. Confirmed
|
|
156
|
-
* by bisection: the identical payload returns 200 with the paragraph removed and
|
|
157
|
-
* 400 with it present, and entry count and payload size are not the factors.
|
|
158
|
-
*/
|
|
122
|
+
/** Documentation paragraphs must move from `system` into `messages`. */
|
|
159
123
|
const DOCS_ANCHOR = "Pi documentation";
|
|
160
124
|
|
|
161
125
|
/** Lone surrogates are invalid UTF-8 and are rejected outright. */
|
|
@@ -163,14 +127,7 @@ function sanitizePrompt(text: string): string {
|
|
|
163
127
|
return text.replace(/[\uD800-\uDFFF]/gu, "\uFFFD");
|
|
164
128
|
}
|
|
165
129
|
|
|
166
|
-
/**
|
|
167
|
-
* Splits pi's system prompt into what may stay in `system` and what must move
|
|
168
|
-
* into the first user message.
|
|
169
|
-
*
|
|
170
|
-
* An unrecognised prompt shape (no docs paragraph found) is moved whole, the
|
|
171
|
-
* same conservative fallback the vendor uses: better to shift the entire prompt
|
|
172
|
-
* into messages than to guess and trigger the 400.
|
|
173
|
-
*/
|
|
130
|
+
/** Splits the system prompt, moving unknown shapes conservatively. */
|
|
174
131
|
export function splitSystemPrompt(prompt: string): { systemText?: string; messageText: string } {
|
|
175
132
|
const paragraphs = sanitizePrompt(prompt).split(/\n\n+/);
|
|
176
133
|
const docs = paragraphs.filter((p) => p.includes(DOCS_ANCHOR));
|
|
@@ -183,15 +140,7 @@ export function splitSystemPrompt(prompt: string): { systemText?: string; messag
|
|
|
183
140
|
};
|
|
184
141
|
}
|
|
185
142
|
|
|
186
|
-
/**
|
|
187
|
-
* Inserts text as its own cache-controlled block ahead of the first user
|
|
188
|
-
* message.
|
|
189
|
-
*
|
|
190
|
-
* A separate block rather than merged text, so the cache prefix ends before the
|
|
191
|
-
* user's own words and a new conversation with a different first message still
|
|
192
|
-
* reads this from cache. `cache_control` is explicit because the message-level
|
|
193
|
-
* breakpoint elsewhere only covers the last user message.
|
|
194
|
-
*/
|
|
143
|
+
/** Prepends a cache-controlled block to the first user message. */
|
|
195
144
|
export function prependPromptBlock(messages: any[], text: string): void {
|
|
196
145
|
const firstUser = (messages ?? []).find((m) => m?.role === "user");
|
|
197
146
|
if (!firstUser || !text) return;
|
|
@@ -213,16 +162,12 @@ export function selectBetas(body: unknown, extra: string[] = []): string {
|
|
|
213
162
|
return [...new Set(selected)].join(",");
|
|
214
163
|
}
|
|
215
164
|
|
|
216
|
-
/**
|
|
217
|
-
* Headers presenting this client as Claude Code.
|
|
218
|
-
* Merged over whatever pi already set.
|
|
219
|
-
*/
|
|
165
|
+
/** Headers presenting this client as Claude Code. */
|
|
220
166
|
export function clientIdentityHeaders(body?: unknown, existingBetas?: string): Record<string, string> {
|
|
221
167
|
const incoming = (existingBetas ?? "").split(",").map((b) => b.trim()).filter(Boolean);
|
|
222
168
|
return {
|
|
223
169
|
"user-agent": USER_AGENT,
|
|
224
|
-
//
|
|
225
|
-
// to send, then puts them in the body's `betas` field.
|
|
170
|
+
// Pi copies this header into the request body's betas field.
|
|
226
171
|
"anthropic-beta": selectBetas(body, incoming),
|
|
227
172
|
"anthropic-version": "2023-06-01",
|
|
228
173
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CLAUDE_CODE_VERSION } from "./client-identity.ts";
|
|
3
|
+
|
|
4
|
+
/** Resolves the stable Claude account UUID exposed by the CLI bootstrap API. */
|
|
5
|
+
|
|
6
|
+
const resolved = new Map<string, string>();
|
|
7
|
+
const pending = new Map<string, Promise<string | undefined>>();
|
|
8
|
+
|
|
9
|
+
function tokenKey(accessToken: string): string {
|
|
10
|
+
return createHash("sha256").update(accessToken).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Synchronous lookup for request routing; never starts network I/O. */
|
|
14
|
+
export function cachedAnthropicAccountIdentity(accessToken: string): string | undefined {
|
|
15
|
+
return resolved.get(tokenKey(accessToken));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function anthropicAccountIdentity(
|
|
19
|
+
accessToken: string,
|
|
20
|
+
fetchImpl: typeof fetch = fetch,
|
|
21
|
+
): Promise<string | undefined> {
|
|
22
|
+
if (!accessToken.startsWith("sk-ant-oat")) return undefined;
|
|
23
|
+
|
|
24
|
+
const key = tokenKey(accessToken);
|
|
25
|
+
const cached = resolved.get(key);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
const active = pending.get(key);
|
|
28
|
+
if (active) return active;
|
|
29
|
+
|
|
30
|
+
const request = (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetchImpl("https://api.anthropic.com/api/claude_cli/bootstrap?entrypoint=cli", {
|
|
33
|
+
headers: {
|
|
34
|
+
Accept: "application/json",
|
|
35
|
+
Authorization: `Bearer ${accessToken}`,
|
|
36
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
37
|
+
"user-agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
|
|
38
|
+
},
|
|
39
|
+
signal: AbortSignal.timeout(5_000),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) return undefined;
|
|
42
|
+
const body = await response.json() as any;
|
|
43
|
+
const identity = body?.oauth_account?.account_uuid;
|
|
44
|
+
if (typeof identity !== "string" || !identity) return undefined;
|
|
45
|
+
resolved.set(key, identity);
|
|
46
|
+
return identity;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
})().finally(() => pending.delete(key));
|
|
51
|
+
|
|
52
|
+
pending.set(key, request);
|
|
53
|
+
return request;
|
|
54
|
+
}
|
|
@@ -149,6 +149,8 @@ export interface RefreshOptions {
|
|
|
149
149
|
baseDelayMs?: number;
|
|
150
150
|
fetchImpl?: typeof fetch;
|
|
151
151
|
now?: () => number;
|
|
152
|
+
/** Bounds the entire refresh, including retries. */
|
|
153
|
+
signal?: AbortSignal;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/**
|
|
@@ -172,6 +174,7 @@ export async function refreshToken(options: RefreshOptions): Promise<TokenSet> {
|
|
|
172
174
|
const response = await doFetch(TOKEN_URL, {
|
|
173
175
|
method: "POST",
|
|
174
176
|
headers: TOKEN_HEADERS,
|
|
177
|
+
signal: options.signal,
|
|
175
178
|
body: JSON.stringify({
|
|
176
179
|
grant_type: "refresh_token",
|
|
177
180
|
refresh_token: options.refreshToken,
|
|
@@ -1,33 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { refreshAbortSignal } from "../accounts/routing.ts";
|
|
5
|
+
import {
|
|
6
|
+
configPath as defaultConfigPath,
|
|
7
|
+
loadAccounts,
|
|
8
|
+
saveAccount,
|
|
9
|
+
statePath,
|
|
10
|
+
type Account,
|
|
11
|
+
type QuotaSnapshot,
|
|
12
|
+
} from "./store.ts";
|
|
13
|
+
import { refreshToken, type RefreshOptions, type TokenSet } from "./oauth.ts";
|
|
3
14
|
|
|
4
|
-
/**
|
|
5
|
-
* Quota tracking.
|
|
6
|
-
*
|
|
7
|
-
* There are two sources, and the cheap one is strongly preferred:
|
|
8
|
-
*
|
|
9
|
-
* 1. Response headers. Every `/v1/messages` reply carries the same numbers
|
|
10
|
-
* as the usage endpoint, so the account actually serving traffic keeps
|
|
11
|
-
* its snapshot current at no request cost at all.
|
|
12
|
-
* 2. The usage endpoint. Only needed for accounts that are NOT serving
|
|
13
|
-
* traffic, since routing compares accounts and an idle one would
|
|
14
|
-
* otherwise never update.
|
|
15
|
-
*
|
|
16
|
-
* Polling used to run on a 5 minute timer regardless of activity. That is what
|
|
17
|
-
* the backoff below exists for: the endpoint rate limits, it answers 429 with
|
|
18
|
-
* `retry-after: 0`, and `pollQuota` reports failure as `undefined`, so the
|
|
19
|
-
* throttling was invisible and simply left routing on stale data.
|
|
20
|
-
*/
|
|
15
|
+
/** Uses free response headers first, polling only stale idle accounts. */
|
|
21
16
|
|
|
22
17
|
const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
23
18
|
const TIMEOUT_MS = 10_000;
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*/
|
|
19
|
+
/** Refresh early enough to absorb transient OAuth rate limits before expiry. */
|
|
20
|
+
export const ACCESS_REFRESH_WINDOW_MS = 4 * 60 * 60_000;
|
|
21
|
+
/** Background refresh cadence; each process adds a small startup jitter. */
|
|
22
|
+
export const ACCESS_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
23
|
+
const REFRESH_LOCK_TTL_MS = 60_000;
|
|
24
|
+
const REFRESH_JOIN_WAIT_MS = 16_000;
|
|
25
|
+
/** Snapshot lifetime and minimum interval between polls. */
|
|
31
26
|
export const QUOTA_FRESH_MS = 10 * 60_000;
|
|
32
27
|
/** After a 429, wait at least this long before touching the endpoint again. */
|
|
33
28
|
export const QUOTA_BACKOFF_MS = 15 * 60_000;
|
|
@@ -95,15 +90,8 @@ export function isFresh(quota: QuotaSnapshot | undefined, now = Date.now()): boo
|
|
|
95
90
|
}
|
|
96
91
|
|
|
97
92
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* Anthropic reports utilisation here as a FRACTION (`0.16`), while the usage
|
|
101
|
-
* endpoint reports a PERCENT (`16`). Verified against the same account at the
|
|
102
|
-
* same moment, including matching reset timestamps. Scaling by 100 is what
|
|
103
|
-
* makes the two sources comparable, so do not drop it.
|
|
104
|
-
*
|
|
105
|
-
* Returns undefined when the headers are absent, which is normal: they do not
|
|
106
|
-
* appear on 4xx replies, and non-Anthropic transports may not expose them.
|
|
93
|
+
* Parses response quota headers. Header utilization is a fraction, while the
|
|
94
|
+
* usage endpoint returns a percentage, so header values are scaled by 100.
|
|
107
95
|
*/
|
|
108
96
|
export function parseQuotaHeaders(
|
|
109
97
|
headers: Record<string, unknown> | undefined,
|
|
@@ -145,12 +133,7 @@ export function parseQuotaHeaders(
|
|
|
145
133
|
return { five_hour, seven_day, checkedAt: now, source: "headers" };
|
|
146
134
|
}
|
|
147
135
|
|
|
148
|
-
/**
|
|
149
|
-
* Merges a header-derived snapshot into an account.
|
|
150
|
-
*
|
|
151
|
-
* Scoped per-model limits only come from the usage endpoint, so they are
|
|
152
|
-
* carried over from the previous snapshot rather than dropped.
|
|
153
|
-
*/
|
|
136
|
+
/** Merges header quota while preserving polled model limits. */
|
|
154
137
|
export function applyQuotaHeaders(
|
|
155
138
|
accountId: string,
|
|
156
139
|
headers: Record<string, unknown> | undefined,
|
|
@@ -163,10 +146,7 @@ export function applyQuotaHeaders(
|
|
|
163
146
|
const account = storage?.accounts.find((a) => a.id === accountId);
|
|
164
147
|
if (!account) return false;
|
|
165
148
|
|
|
166
|
-
//
|
|
167
|
-
// steps over windows of hours. Rewriting two credential files per request to
|
|
168
|
-
// store an unchanged number is pure write amplification, and on Windows each
|
|
169
|
-
// rewrite is another chance for the rename to collide with a file lock.
|
|
149
|
+
// Skip unchanged values to avoid credential writes on every response.
|
|
170
150
|
const previous = account.quota;
|
|
171
151
|
const unchanged =
|
|
172
152
|
previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
|
|
@@ -177,12 +157,7 @@ export function applyQuotaHeaders(
|
|
|
177
157
|
return true;
|
|
178
158
|
}
|
|
179
159
|
|
|
180
|
-
/**
|
|
181
|
-
* Polls one account. Returns undefined rather than throwing on failure.
|
|
182
|
-
*
|
|
183
|
-
* Pass `accountId` so a 429 registers backoff; without it the caller can
|
|
184
|
-
* hammer a throttled endpoint and silently keep stale quota.
|
|
185
|
-
*/
|
|
160
|
+
/** Polls one account and records rate-limit backoff. */
|
|
186
161
|
export async function pollQuota(
|
|
187
162
|
accessToken: string,
|
|
188
163
|
accountId?: string,
|
|
@@ -212,34 +187,208 @@ export async function pollQuota(
|
|
|
212
187
|
}
|
|
213
188
|
}
|
|
214
189
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
190
|
+
export function accessTokenNeedsRefresh(account: Account, now = Date.now()): boolean {
|
|
191
|
+
return !account.access
|
|
192
|
+
|| typeof account.expires !== "number"
|
|
193
|
+
|| account.expires - now <= ACCESS_REFRESH_WINDOW_MS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type RefreshTokenFn = (options: RefreshOptions) => Promise<TokenSet>;
|
|
197
|
+
|
|
198
|
+
export interface EnsureAccessTokenOptions {
|
|
199
|
+
config?: string;
|
|
200
|
+
now?: () => number;
|
|
201
|
+
refresh?: RefreshTokenFn;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface RefreshAllQuotaOptions extends EnsureAccessTokenOptions {
|
|
205
|
+
poll?: typeof pollQuota;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface RefreshLock {
|
|
209
|
+
release(): void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const refreshes = new Map<string, Promise<string | undefined>>();
|
|
213
|
+
|
|
214
|
+
function refreshLockPath(accountId: string, config: string): string {
|
|
215
|
+
const id = createHash("sha256").update(accountId).digest("hex").slice(0, 16);
|
|
216
|
+
return `${statePath(config)}.refresh-${id}.lock`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Cross-process exclusion for Anthropic's rotating refresh tokens. */
|
|
220
|
+
function acquireRefreshLock(accountId: string, config: string, now: number): RefreshLock | undefined {
|
|
221
|
+
const path = refreshLockPath(accountId, config);
|
|
222
|
+
const owner = randomUUID();
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
224
|
+
|
|
225
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
226
|
+
try {
|
|
227
|
+
const fd = openSync(path, "wx", 0o600);
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + REFRESH_LOCK_TTL_MS }), "utf8");
|
|
230
|
+
} finally {
|
|
231
|
+
closeSync(fd);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
release() {
|
|
235
|
+
try {
|
|
236
|
+
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
237
|
+
if (current?.owner === owner) rmSync(path, { force: true });
|
|
238
|
+
} catch {
|
|
239
|
+
// A stale or externally removed lock needs no cleanup.
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
} catch (error: any) {
|
|
244
|
+
if (error?.code !== "EEXIST") throw error;
|
|
245
|
+
try {
|
|
246
|
+
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
247
|
+
if (Number(current?.expiresAt) > now) return undefined;
|
|
248
|
+
rmSync(path, { force: true });
|
|
249
|
+
} catch {
|
|
250
|
+
// A malformed lock is stale; remove it and retry once.
|
|
251
|
+
try { rmSync(path, { force: true }); } catch { /* best effort */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
219
254
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
|
|
223
|
-
// Persist immediately: Anthropic may rotate the refresh token, and losing the
|
|
224
|
-
// new one would invalidate the account.
|
|
225
|
-
saveAccount({
|
|
226
|
-
...account,
|
|
227
|
-
access: refreshed.access,
|
|
228
|
-
refresh: refreshed.refresh,
|
|
229
|
-
expires: refreshed.expires,
|
|
230
|
-
lastRefreshedAt: Date.now(),
|
|
231
|
-
});
|
|
232
|
-
return refreshed.access;
|
|
255
|
+
return undefined;
|
|
233
256
|
}
|
|
234
257
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
258
|
+
function storedAccount(accountId: string, config: string): Account | undefined {
|
|
259
|
+
return loadAccounts(config)?.accounts.find((candidate) => candidate.id === accountId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function usableAccess(account: Account | undefined, now: number): string | undefined {
|
|
263
|
+
return account?.access && typeof account.expires === "number" && account.expires > now
|
|
264
|
+
? account.access
|
|
265
|
+
: undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function joinConcurrentRefresh(
|
|
269
|
+
account: Account,
|
|
270
|
+
config: string,
|
|
271
|
+
now: () => number,
|
|
272
|
+
): Promise<string | undefined> {
|
|
273
|
+
const deadline = now() + REFRESH_JOIN_WAIT_MS;
|
|
274
|
+
while (now() < deadline) {
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
276
|
+
const latest = storedAccount(account.id, config);
|
|
277
|
+
const changed = latest && (
|
|
278
|
+
latest.access !== account.access
|
|
279
|
+
|| latest.refresh !== account.refresh
|
|
280
|
+
|| (latest.expires ?? 0) > (account.expires ?? 0) + 60_000
|
|
281
|
+
);
|
|
282
|
+
if (changed && !accessTokenNeedsRefresh(latest, now())) return latest.access;
|
|
283
|
+
}
|
|
284
|
+
return usableAccess(storedAccount(account.id, config) ?? account, now());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function refreshErrorMessage(error: unknown): string {
|
|
288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
+
return message.replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 500);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function refreshAccountNow(
|
|
293
|
+
account: Account,
|
|
294
|
+
options: EnsureAccessTokenOptions,
|
|
295
|
+
): Promise<string | undefined> {
|
|
296
|
+
const config = options.config ?? defaultConfigPath();
|
|
297
|
+
const now = options.now ?? Date.now;
|
|
298
|
+
const refresh = options.refresh ?? refreshToken;
|
|
299
|
+
let latest = storedAccount(account.id, config) ?? account;
|
|
300
|
+
if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
|
|
301
|
+
if (!latest.refresh) return usableAccess(latest, now());
|
|
302
|
+
|
|
303
|
+
const lock = acquireRefreshLock(account.id, config, now());
|
|
304
|
+
if (!lock) return joinConcurrentRefresh(latest, config, now);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// The lock winner re-reads so it never spends a refresh token rotated by a
|
|
308
|
+
// different process immediately before the lock was acquired.
|
|
309
|
+
latest = storedAccount(account.id, config) ?? latest;
|
|
310
|
+
if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
|
|
311
|
+
if (!latest.refresh) return usableAccess(latest, now());
|
|
312
|
+
|
|
313
|
+
const sourceRefresh = latest.refresh;
|
|
314
|
+
try {
|
|
315
|
+
const refreshed = await refresh({
|
|
316
|
+
refreshToken: sourceRefresh,
|
|
317
|
+
signal: refreshAbortSignal(),
|
|
318
|
+
});
|
|
319
|
+
const current = storedAccount(account.id, config) ?? latest;
|
|
320
|
+
// A changed refresh token is a stronger, newer write. Never overwrite it
|
|
321
|
+
// with the result of an older token family.
|
|
322
|
+
if (current.refresh !== sourceRefresh) return usableAccess(current, now());
|
|
323
|
+
saveAccount({
|
|
324
|
+
...current,
|
|
325
|
+
access: refreshed.access,
|
|
326
|
+
refresh: refreshed.refresh,
|
|
327
|
+
expires: refreshed.expires,
|
|
328
|
+
lastRefreshedAt: now(),
|
|
329
|
+
lastRefreshError: undefined,
|
|
330
|
+
}, config);
|
|
331
|
+
return refreshed.access;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const current = storedAccount(account.id, config) ?? latest;
|
|
334
|
+
if (current.refresh === sourceRefresh) {
|
|
335
|
+
saveAccount({ ...current, lastRefreshError: refreshErrorMessage(error) }, config);
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
lock.release();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Returns a usable token, proactively rotating it well before expiry. */
|
|
345
|
+
export async function ensureAccessToken(
|
|
346
|
+
account: Account,
|
|
347
|
+
options: EnsureAccessTokenOptions = {},
|
|
348
|
+
): Promise<string | undefined> {
|
|
349
|
+
const config = options.config ?? defaultConfigPath();
|
|
350
|
+
const now = options.now ?? Date.now;
|
|
351
|
+
if (!accessTokenNeedsRefresh(account, now())) return account.access;
|
|
352
|
+
if (!account.refresh) return usableAccess(account, now());
|
|
353
|
+
|
|
354
|
+
const key = `${config}\0${account.id}`;
|
|
355
|
+
const active = refreshes.get(key);
|
|
356
|
+
if (active) return active;
|
|
357
|
+
const pending = refreshAccountNow(account, options).finally(() => refreshes.delete(key));
|
|
358
|
+
refreshes.set(key, pending);
|
|
359
|
+
return pending;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Rotates every due sidecar token independently of quota freshness. */
|
|
363
|
+
export async function refreshDueAccessTokens(
|
|
364
|
+
config = defaultConfigPath(),
|
|
365
|
+
options: EnsureAccessTokenOptions = {},
|
|
366
|
+
): Promise<number> {
|
|
367
|
+
const now = options.now ?? Date.now;
|
|
368
|
+
const accounts = (loadAccounts(config)?.accounts ?? []).filter(
|
|
369
|
+
(account) => account.enabled !== false && account.type === "oauth" && accessTokenNeedsRefresh(account, now()),
|
|
370
|
+
);
|
|
371
|
+
const results = await Promise.all(accounts.map(async (account) => {
|
|
372
|
+
try {
|
|
373
|
+
return Boolean(await ensureAccessToken(account, { ...options, config }));
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}));
|
|
378
|
+
return results.filter(Boolean).length;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Refreshes due access tokens, then polls only stale quota snapshots. */
|
|
382
|
+
export async function refreshAllQuota(
|
|
383
|
+
force = false,
|
|
384
|
+
config = defaultConfigPath(),
|
|
385
|
+
options: RefreshAllQuotaOptions = {},
|
|
386
|
+
): Promise<number> {
|
|
387
|
+
await refreshDueAccessTokens(config, options);
|
|
388
|
+
const storage = loadAccounts(config);
|
|
241
389
|
if (!storage) return 0;
|
|
242
|
-
const now = Date.now();
|
|
390
|
+
const now = options.now?.() ?? Date.now();
|
|
391
|
+
const poll = options.poll ?? pollQuota;
|
|
243
392
|
|
|
244
393
|
const stale = storage.accounts.filter(
|
|
245
394
|
(a) => a.enabled !== false && a.type === "oauth"
|
|
@@ -248,11 +397,16 @@ export async function refreshAllQuota(force = false): Promise<number> {
|
|
|
248
397
|
|
|
249
398
|
const results = await Promise.all(stale.map(async (account) => {
|
|
250
399
|
try {
|
|
251
|
-
const token = await ensureAccessToken(account);
|
|
400
|
+
const token = await ensureAccessToken(account, { ...options, config });
|
|
252
401
|
if (!token) return false;
|
|
253
|
-
const quota = await
|
|
402
|
+
const quota = await poll(token, account.id);
|
|
254
403
|
if (!quota) return false;
|
|
255
|
-
|
|
404
|
+
// Do not restore the pre-refresh account snapshot here. That used to
|
|
405
|
+
// overwrite a freshly rotated access/refresh pair with the now-invalid
|
|
406
|
+
// old pair immediately after a successful quota poll.
|
|
407
|
+
const current = storedAccount(account.id, config);
|
|
408
|
+
if (!current || current.access !== token) return false;
|
|
409
|
+
saveAccount({ ...current, quota }, config);
|
|
256
410
|
return true;
|
|
257
411
|
} catch {
|
|
258
412
|
return false;
|