@jameslovespancakes/pi-plus 1.0.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/config/pi-plus.example.json +60 -0
  4. package/config/skills/model-routing/SKILL.md +86 -0
  5. package/images/board_demo.png +0 -0
  6. package/images/pi-plus.svg +10 -0
  7. package/images/pi-plus_demo.png +0 -0
  8. package/images/provider_demo.png +0 -0
  9. package/images/remote_demo.png +0 -0
  10. package/images/usage_demo.png +0 -0
  11. package/package.json +67 -0
  12. package/server/board-server.mjs +641 -0
  13. package/server/package.json +17 -0
  14. package/src/core/accounts/registry.ts +93 -0
  15. package/src/core/anthropic/client-identity.ts +241 -0
  16. package/src/core/anthropic/models.ts +69 -0
  17. package/src/core/anthropic/oauth.ts +208 -0
  18. package/src/core/anthropic/quota.ts +253 -0
  19. package/src/core/anthropic/routing.ts +168 -0
  20. package/src/core/anthropic/store.ts +225 -0
  21. package/src/core/anthropic/vendor/README.md +36 -0
  22. package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
  23. package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
  24. package/src/core/anthropic/xxhash64.ts +33 -0
  25. package/src/core/catalog/quality.ts +314 -0
  26. package/src/core/codex/oauth.ts +129 -0
  27. package/src/core/codex/quota.ts +88 -0
  28. package/src/core/codex/store.ts +97 -0
  29. package/src/core/config.ts +169 -0
  30. package/src/core/env.ts +58 -0
  31. package/src/core/exec/process.ts +146 -0
  32. package/src/core/exec/ssh-config.ts +157 -0
  33. package/src/core/oauth/pkce.ts +88 -0
  34. package/src/core/policy/policy.ts +183 -0
  35. package/src/core/quota/pool.ts +64 -0
  36. package/src/core/quota/usage-source.ts +289 -0
  37. package/src/core/store.ts +43 -0
  38. package/src/domains/agents/board-setup.ts +409 -0
  39. package/src/domains/agents/index.ts +462 -0
  40. package/src/domains/models/catalog-tool.ts +361 -0
  41. package/src/domains/models/index.ts +14 -0
  42. package/src/domains/models/policy-gate.ts +169 -0
  43. package/src/domains/models/provider-picker.ts +208 -0
  44. package/src/domains/remote/config-path.ts +41 -0
  45. package/src/domains/remote/index.ts +866 -0
  46. package/src/domains/remote/setup.ts +425 -0
  47. package/src/domains/setup/index.ts +220 -0
  48. package/src/domains/subscriptions/accounts-picker.ts +178 -0
  49. package/src/domains/subscriptions/accounts.ts +242 -0
  50. package/src/domains/subscriptions/footer.ts +182 -0
  51. package/src/domains/subscriptions/index.ts +42 -0
  52. package/src/domains/subscriptions/provider.ts +219 -0
  53. package/src/domains/subscriptions/providers/anthropic.ts +149 -0
  54. package/src/domains/subscriptions/providers/codex.ts +148 -0
  55. package/src/domains/subscriptions/routing.ts +72 -0
  56. package/src/services/usage-service.ts +186 -0
  57. package/src/ui/format.ts +73 -0
  58. package/src/ui/usage-bars.ts +154 -0
  59. package/src/vendor/anthropic.ts +109 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Provider-agnostic subscription accounts.
3
+ *
4
+ * Today only Anthropic implements this, through
5
+ * `domains/subscriptions/providers/anthropic.ts`. The point of the indirection
6
+ * is that `/account` and `/routing` contain no provider-specific logic, so a
7
+ * second provider is a new adapter rather than a new command surface.
8
+ *
9
+ * Structural types only; nothing here imports pi.
10
+ */
11
+
12
+ export interface ManagedAccount {
13
+ id: string;
14
+ label: string;
15
+ /** False when the account is configured but deliberately skipped. */
16
+ enabled: boolean;
17
+ /** OAuth expiry, when the provider exposes one. */
18
+ expiresAt?: number;
19
+ /** True for the account pi itself is authenticated as. */
20
+ primary?: boolean;
21
+ }
22
+
23
+ /**
24
+ * `standard` uses the main account first and falls back only when it is
25
+ * exhausted. `optimal` balances across accounts by remaining quota and time to
26
+ * reset, keeping session caches sticky.
27
+ */
28
+ export type RoutingMode = "standard" | "optimal";
29
+
30
+ export interface AccountUi {
31
+ input(title: string, placeholder?: string): Promise<string | undefined>;
32
+ confirm(title: string, message: string): Promise<boolean>;
33
+ notify(message: string, type?: "info" | "warning" | "error"): void;
34
+ }
35
+
36
+ export interface AccountContext {
37
+ ui: AccountUi;
38
+ hasUI: boolean;
39
+ /** Opens a URL in the user's browser, for OAuth flows. */
40
+ openBrowser(url: string): Promise<void>;
41
+ }
42
+
43
+ export interface RoutingSupport {
44
+ get(): Promise<RoutingMode>;
45
+ set(mode: RoutingMode): Promise<RoutingMode>;
46
+ describe(mode: RoutingMode): string;
47
+ }
48
+
49
+ export interface AccountProvider {
50
+ /** Matches the pi provider id, e.g. "anthropic". */
51
+ id: string;
52
+ /** Human name, e.g. "Claude". */
53
+ label: string;
54
+ list(): Promise<ManagedAccount[]>;
55
+ /** Returns the label of the account that was added. */
56
+ add(ctx: AccountContext, label: string): Promise<string | undefined>;
57
+ /** Returns the label of the account that was reauthorized. */
58
+ reauth(ctx: AccountContext, accountId: string): Promise<string | undefined>;
59
+ /**
60
+ * Enables or disables one account without removing its credentials.
61
+ * Optional: a provider that cannot suspend accounts simply omits it.
62
+ */
63
+ setEnabled?(accountId: string, enabled: boolean): Promise<void>;
64
+ /**
65
+ * Changes an account's display label. Credentials are untouched, so this is
66
+ * purely cosmetic and never needs a re-authorization.
67
+ */
68
+ rename?(accountId: string, label: string): Promise<void>;
69
+ routing?: RoutingSupport;
70
+ }
71
+
72
+ const providers = new Map<string, AccountProvider>();
73
+
74
+ export function registerAccountProvider(provider: AccountProvider): void {
75
+ providers.set(provider.id, provider);
76
+ }
77
+
78
+ export function accountProviders(): AccountProvider[] {
79
+ return [...providers.values()].sort((a, b) => a.id.localeCompare(b.id));
80
+ }
81
+
82
+ export function accountProvider(id: string): AccountProvider | undefined {
83
+ return providers.get(id.toLowerCase());
84
+ }
85
+
86
+ /** Providers that can balance across more than one account. */
87
+ export function routableProviders(): AccountProvider[] {
88
+ return accountProviders().filter((provider) => provider.routing !== undefined);
89
+ }
90
+
91
+ export function resetAccountProviders(): void {
92
+ providers.clear();
93
+ }
@@ -0,0 +1,241 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { xxhash64 } from "./xxhash64.ts";
3
+
4
+ /**
5
+ * Claude Code client identity headers.
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
+ * ---------------------------------------------------------------------------
26
+ */
27
+
28
+ /** Pinned to the Claude Code release being imitated. */
29
+ export const CLAUDE_CODE_VERSION = "2.1.258";
30
+
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
+ */
42
+ const CCH_SEED = 0x4d659218e32a3268n;
43
+ const CCH_SALT = "59cf53e54c78";
44
+ const CCH_POSITIONS = [4, 7, 20];
45
+ const CCH_PLACEHOLDER = "cch=00000;";
46
+
47
+ const CCH_SIGNED = /("system":\[\{"type":"text","text":"x-anthropic-billing-header: cc_version=[^;"]+; cc_entrypoint=[^;"]+; )cch=[0-9a-f]{5};/;
48
+ const CCH_UNSIGNED = /("system":\[\{"type":"text","text":"x-anthropic-billing-header: cc_version=[^;"]+; cc_entrypoint=[^;"]+; )cch=00000;/;
49
+
50
+ /** Final text block of the first user message; a few characters are sampled. */
51
+ export function firstUserText(messages: any[] | undefined): string {
52
+ const message = (messages ?? []).find((m) => m?.role === "user");
53
+ if (!message) return "";
54
+ if (typeof message.content === "string") return message.content;
55
+ if (!Array.isArray(message.content)) return "";
56
+
57
+ const texts = message.content.filter((b: any) => b?.type === "text" && typeof b.text === "string");
58
+ const command = texts.find((b: any) => b.text.includes("<command-name>"));
59
+ if (command) return command.text.slice(command.text.indexOf("<command-name>"));
60
+ return texts.at(-1)?.text ?? "";
61
+ }
62
+
63
+ /** Three hex characters appended to cc_version. */
64
+ export function ccVersionSuffix(sample: string, version = CLAUDE_CODE_VERSION): string {
65
+ const sampled = CCH_POSITIONS.map((i) => sample[i] ?? "0").join("");
66
+ return createHash("sha256").update(`${CCH_SALT}${sampled}${version}`).digest("hex").slice(0, 3);
67
+ }
68
+
69
+ /** Billing header with a placeholder, ready for `signRequestBody`. */
70
+ export function billingHeader(sample: string, entrypoint = "cli", version = CLAUDE_CODE_VERSION): string {
71
+ return `x-anthropic-billing-header: cc_version=${version}.${ccVersionSuffix(sample, version)}; cc_entrypoint=${entrypoint}; ${CCH_PLACEHOLDER}`;
72
+ }
73
+
74
+ /**
75
+ * Fills in the `cch` placeholder of an already-serialised request body.
76
+ *
77
+ * `model` and `max_tokens` are blanked before hashing, matching the official
78
+ * canonicalisation. Returns the input unchanged when no billing header is
79
+ * present, so this is a no-op for non-Anthropic traffic.
80
+ */
81
+ export async function signRequestBody(bodyString: string): Promise<string> {
82
+ if (!CCH_SIGNED.test(bodyString)) return bodyString;
83
+
84
+ const unsigned = bodyString.replace(CCH_SIGNED, `$1${CCH_PLACEHOLDER}`);
85
+ const canonical = JSON.parse(unsigned);
86
+ canonical.model = "";
87
+ delete canonical.max_tokens;
88
+
89
+ const bytes = new TextEncoder().encode(JSON.stringify(canonical));
90
+ const token = ((await xxhash64(bytes, CCH_SEED)) & 0xfffffn).toString(16).padStart(5, "0");
91
+ return unsigned.replace(CCH_UNSIGNED, `$1cch=${token};`);
92
+ }
93
+ const USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`;
94
+ const STAINLESS_PACKAGE_VERSION = "0.112.1";
95
+ const STAINLESS_RUNTIME_VERSION = "v26.3.0";
96
+
97
+ /** Sent when the request carries no tools. */
98
+ const BASE_BETAS = [
99
+ "oauth-2025-04-20",
100
+ "interleaved-thinking-2025-05-14",
101
+ "thinking-token-count-2026-05-13",
102
+ "context-management-2025-06-27",
103
+ "prompt-caching-scope-2026-01-05",
104
+ "advisor-tool-2026-03-01",
105
+ "advanced-tool-use-2025-11-20",
106
+ "extended-cache-ttl-2025-04-11",
107
+ "cache-diagnosis-2026-04-07",
108
+ ];
109
+
110
+ /** Sent for a full agent turn: tools, system blocks and thinking together. */
111
+ const FULL_AGENT_BETAS = [
112
+ ...BASE_BETAS,
113
+ "claude-code-20250219",
114
+ "mid-conversation-system-2026-04-07",
115
+ "effort-2025-11-24",
116
+ "fallback-credit-2026-06-01",
117
+ ];
118
+
119
+ function stainlessOS(): string {
120
+ switch (process.platform) {
121
+ case "darwin": return "MacOS";
122
+ case "win32": return "Windows";
123
+ case "linux": return "Linux";
124
+ case "freebsd": return "FreeBSD";
125
+ default: return "Unknown";
126
+ }
127
+ }
128
+
129
+ function stainlessArch(): string {
130
+ switch (process.arch) {
131
+ case "arm64": return "arm64";
132
+ case "x64": return "x64";
133
+ case "ia32": return "x32";
134
+ default: return process.arch;
135
+ }
136
+ }
137
+
138
+ /** One session id per process, matching the real CLI's lifetime. */
139
+ let sessionId: string | undefined;
140
+ function currentSessionId(): string {
141
+ return (sessionId ??= randomUUID());
142
+ }
143
+
144
+ function hasFullAgentShape(body: any): boolean {
145
+ return Array.isArray(body?.tools) && body.tools.length > 0
146
+ && Array.isArray(body?.system)
147
+ && !!body?.thinking && typeof body.thinking === "object";
148
+ }
149
+
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
+ */
159
+ const DOCS_ANCHOR = "Pi documentation";
160
+
161
+ /** Lone surrogates are invalid UTF-8 and are rejected outright. */
162
+ function sanitizePrompt(text: string): string {
163
+ return text.replace(/[\uD800-\uDFFF]/gu, "\uFFFD");
164
+ }
165
+
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
+ */
174
+ export function splitSystemPrompt(prompt: string): { systemText?: string; messageText: string } {
175
+ const paragraphs = sanitizePrompt(prompt).split(/\n\n+/);
176
+ const docs = paragraphs.filter((p) => p.includes(DOCS_ANCHOR));
177
+ if (docs.length === 0) return { messageText: sanitizePrompt(prompt) };
178
+
179
+ const keep = paragraphs.filter((p) => !p.includes(DOCS_ANCHOR));
180
+ return {
181
+ ...(keep.length ? { systemText: keep.join("\n\n") } : {}),
182
+ messageText: docs.join("\n\n"),
183
+ };
184
+ }
185
+
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
+ */
195
+ export function prependPromptBlock(messages: any[], text: string): void {
196
+ const firstUser = (messages ?? []).find((m) => m?.role === "user");
197
+ if (!firstUser || !text) return;
198
+
199
+ const block = { type: "text", text, cache_control: { type: "ephemeral" } };
200
+ if (typeof firstUser.content === "string") {
201
+ firstUser.content = [block, { type: "text", text: firstUser.content }];
202
+ return;
203
+ }
204
+ if (Array.isArray(firstUser.content)) firstUser.content.unshift(block);
205
+ }
206
+
207
+ export function selectBetas(body: unknown, extra: string[] = []): string {
208
+ const selected = hasFullAgentShape(body) ? [...FULL_AGENT_BETAS] : [...BASE_BETAS];
209
+ for (const beta of extra) {
210
+ const trimmed = beta.trim();
211
+ if (trimmed) selected.push(trimmed);
212
+ }
213
+ return [...new Set(selected)].join(",");
214
+ }
215
+
216
+ /**
217
+ * Headers presenting this client as Claude Code.
218
+ * Merged over whatever pi already set.
219
+ */
220
+ export function clientIdentityHeaders(body?: unknown, existingBetas?: string): Record<string, string> {
221
+ const incoming = (existingBetas ?? "").split(",").map((b) => b.trim()).filter(Boolean);
222
+ return {
223
+ "user-agent": USER_AGENT,
224
+ // Required: pi's Anthropic client reads this header to decide which betas
225
+ // to send, then puts them in the body's `betas` field.
226
+ "anthropic-beta": selectBetas(body, incoming),
227
+ "anthropic-version": "2023-06-01",
228
+ "anthropic-dangerous-direct-browser-access": "true",
229
+ "x-app": "cli",
230
+ "x-client-request-id": randomUUID(),
231
+ "x-claude-code-session-id": currentSessionId(),
232
+ "x-stainless-arch": stainlessArch(),
233
+ "x-stainless-lang": "js",
234
+ "x-stainless-os": stainlessOS(),
235
+ "x-stainless-package-version": STAINLESS_PACKAGE_VERSION,
236
+ "x-stainless-retry-count": "0",
237
+ "x-stainless-runtime": "node",
238
+ "x-stainless-runtime-version": STAINLESS_RUNTIME_VERSION,
239
+ "x-stainless-timeout": "600",
240
+ };
241
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Anthropic model catalogue.
3
+ *
4
+ * These are the models pi's built-in catalogue does not carry (or carries with
5
+ * stale pricing). Extracted so the provider definition lives here rather than
6
+ * in a dependency.
7
+ */
8
+
9
+ export const FABLE_CONTEXT_WINDOW = 1_000_000;
10
+ export const FABLE_MAX_OUTPUT = 128_000;
11
+
12
+ /** Per-million-token USD. `cacheWrite` is the 5-minute tier. */
13
+ export const FABLE_PRICING = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 };
14
+ /** 5.1 halved cache reads. */
15
+ export const FABLE_5_1_PRICING = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 };
16
+
17
+ export interface ModelSpec {
18
+ id: string;
19
+ name: string;
20
+ reasoning: boolean;
21
+ input: ("text" | "image")[];
22
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
23
+ contextWindow: number;
24
+ maxTokens: number;
25
+ }
26
+
27
+ const textImage: ("text" | "image")[] = ["text", "image"];
28
+
29
+ const fable = (id: string, name: string, pricing = FABLE_PRICING): ModelSpec => ({
30
+ id, name, reasoning: true, input: textImage, cost: pricing,
31
+ contextWindow: FABLE_CONTEXT_WINDOW, maxTokens: FABLE_MAX_OUTPUT,
32
+ });
33
+
34
+ export const ANTHROPIC_MODELS: ModelSpec[] = [
35
+ fable("claude-fable-5", "Claude Fable 5"),
36
+ fable("claude-mythos-5", "Claude Mythos 5"),
37
+ fable("claude-fable-5-1", "Claude Fable 5.1", FABLE_5_1_PRICING),
38
+ fable("claude-mythos-5-1", "Claude Mythos 5.1", FABLE_5_1_PRICING),
39
+ {
40
+ id: "claude-opus-5", name: "Claude Opus 5", reasoning: true, input: textImage,
41
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
42
+ contextWindow: 1_000_000, maxTokens: 128_000,
43
+ },
44
+ {
45
+ id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true, input: textImage,
46
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
47
+ contextWindow: 1_000_000, maxTokens: 128_000,
48
+ },
49
+ {
50
+ id: "claude-opus-4-5", name: "Claude Opus 4.5", reasoning: true, input: textImage,
51
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
52
+ contextWindow: 200_000, maxTokens: 64_000,
53
+ },
54
+ {
55
+ id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true, input: textImage,
56
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
57
+ contextWindow: 1_000_000, maxTokens: 128_000,
58
+ },
59
+ {
60
+ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, input: textImage,
61
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
62
+ contextWindow: 200_000, maxTokens: 64_000,
63
+ },
64
+ {
65
+ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: false, input: textImage,
66
+ cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
67
+ contextWindow: 200_000, maxTokens: 64_000,
68
+ },
69
+ ];
@@ -0,0 +1,208 @@
1
+ import { generatePkce, generateState, isTransientNetworkError, parseCallback, parseRetryAfter } from "../oauth/pkce.ts";
2
+
3
+ /**
4
+ * Anthropic OAuth: authorize, exchange, refresh.
5
+ *
6
+ * Extracted from @cortexkit/anthropic-auth-core so the auth path lives in this
7
+ * repo rather than in a dependency we do not control. Token and storage shapes
8
+ * stay byte-compatible with the existing files, so reverting to the vendored
9
+ * package remains possible.
10
+ *
11
+ * The generic half of the flow (PKCE, state, callback parsing, retry
12
+ * classification) lives in core/oauth and is shared with any future provider.
13
+ */
14
+
15
+ /** Public OAuth client id for the Claude CLI. Not a secret. */
16
+ export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
17
+
18
+ export const AUTHORIZE_URLS = {
19
+ console: "https://platform.claude.com/oauth/authorize",
20
+ max: "https://claude.com/cai/oauth/authorize",
21
+ } as const;
22
+
23
+ export const CODE_CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
24
+ export const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
25
+
26
+ /** The upstream client presents itself as axios; the token endpoint expects it. */
27
+ const TOKEN_USER_AGENT = "axios/1.15.2";
28
+
29
+ export const OAUTH_SCOPES = [
30
+ "org:create_api_key",
31
+ "user:profile",
32
+ "user:inference",
33
+ "user:sessions:claude_code",
34
+ "user:mcp_servers",
35
+ "user:file_upload",
36
+ ] as const;
37
+
38
+ /** Refresh deliberately omits `org:create_api_key`. */
39
+ export const REFRESH_SCOPE =
40
+ "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
41
+
42
+ export type AuthorizeMode = keyof typeof AUTHORIZE_URLS;
43
+
44
+ export interface AuthorizeRequest {
45
+ url: string;
46
+ redirectUri: string;
47
+ state: string;
48
+ verifier: string;
49
+ }
50
+
51
+ export interface TokenSet {
52
+ access: string;
53
+ refresh: string;
54
+ /** Absolute epoch ms. */
55
+ expires: number;
56
+ }
57
+
58
+ const TOKEN_HEADERS = {
59
+ "Content-Type": "application/json",
60
+ Accept: "application/json, text/plain, */*",
61
+ "User-Agent": TOKEN_USER_AGENT,
62
+ };
63
+
64
+ /** Step 1: the URL the user opens in a browser. */
65
+ export async function authorize(mode: AuthorizeMode = "max"): Promise<AuthorizeRequest> {
66
+ const pkce = await generatePkce();
67
+ const state = generateState();
68
+
69
+ const url = new URL(AUTHORIZE_URLS[mode]);
70
+ url.searchParams.set("code", "true");
71
+ url.searchParams.set("client_id", CLIENT_ID);
72
+ url.searchParams.set("response_type", "code");
73
+ url.searchParams.set("redirect_uri", CODE_CALLBACK_URL);
74
+ url.searchParams.set("scope", OAUTH_SCOPES.join(" "));
75
+ url.searchParams.set("code_challenge", pkce.challenge);
76
+ url.searchParams.set("code_challenge_method", pkce.method);
77
+ url.searchParams.set("state", state);
78
+
79
+ return { url: url.toString(), redirectUri: CODE_CALLBACK_URL, state, verifier: pkce.verifier };
80
+ }
81
+
82
+ export type ExchangeResult =
83
+ | ({ type: "success" } & TokenSet)
84
+ | { type: "failed"; reason: string };
85
+
86
+ /** Step 2: trade the pasted callback for a token set. */
87
+ export async function exchange(
88
+ pasted: string,
89
+ verifier: string,
90
+ redirectUri: string,
91
+ expectedState?: string,
92
+ ): Promise<ExchangeResult> {
93
+ const callback = parseCallback(pasted);
94
+ if (!callback) return { type: "failed", reason: "could not read a code and state from that input" };
95
+ // Guards against a callback from a different authorization attempt.
96
+ if (expectedState && callback.state !== expectedState) {
97
+ return { type: "failed", reason: "state did not match the request" };
98
+ }
99
+
100
+ const response = await fetch(TOKEN_URL, {
101
+ method: "POST",
102
+ headers: TOKEN_HEADERS,
103
+ body: JSON.stringify({
104
+ code: callback.code,
105
+ state: callback.state,
106
+ grant_type: "authorization_code",
107
+ client_id: CLIENT_ID,
108
+ redirect_uri: redirectUri,
109
+ code_verifier: verifier,
110
+ }),
111
+ });
112
+
113
+ if (!response.ok) {
114
+ const body = await response.text().catch(() => "");
115
+ return { type: "failed", reason: `token endpoint returned ${response.status} ${body.slice(0, 200)}` };
116
+ }
117
+
118
+ const json = await response.json() as { access_token: string; refresh_token: string; expires_in: number };
119
+ return {
120
+ type: "success",
121
+ access: json.access_token,
122
+ refresh: json.refresh_token,
123
+ expires: Date.now() + json.expires_in * 1000,
124
+ };
125
+ }
126
+
127
+ export class OAuthRefreshError extends Error {
128
+ readonly status: number;
129
+ readonly body: string;
130
+ readonly retryAfter?: number;
131
+ /**
132
+ * Duck-typed marker rather than instanceof, so callers across module
133
+ * instances still recognise it. Mirrors the upstream contract.
134
+ */
135
+ readonly isRefreshError = true;
136
+
137
+ constructor(status: number, body: string, retryAfterHeader?: string | null) {
138
+ super(`Anthropic OAuth refresh failed: ${status} ${body}`);
139
+ this.name = "OAuthRefreshError";
140
+ this.status = status;
141
+ this.body = body;
142
+ this.retryAfter = parseRetryAfter(retryAfterHeader);
143
+ }
144
+ }
145
+
146
+ export interface RefreshOptions {
147
+ refreshToken: string;
148
+ maxRetries?: number;
149
+ baseDelayMs?: number;
150
+ fetchImpl?: typeof fetch;
151
+ now?: () => number;
152
+ }
153
+
154
+ /**
155
+ * Step 3: exchange a refresh token for a fresh access token.
156
+ *
157
+ * Retries 5xx and transient network failures with exponential backoff. A 4xx is
158
+ * final: the credential itself was rejected, so retrying cannot help and would
159
+ * only delay surfacing a re-auth prompt.
160
+ */
161
+ export async function refreshToken(options: RefreshOptions): Promise<TokenSet> {
162
+ const doFetch = options.fetchImpl ?? fetch;
163
+ const maxRetries = options.maxRetries ?? 2;
164
+ const baseDelayMs = options.baseDelayMs ?? 500;
165
+
166
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
167
+ if (attempt > 0) {
168
+ await new Promise((done) => setTimeout(done, baseDelayMs * 2 ** (attempt - 1)));
169
+ }
170
+
171
+ try {
172
+ const response = await doFetch(TOKEN_URL, {
173
+ method: "POST",
174
+ headers: TOKEN_HEADERS,
175
+ body: JSON.stringify({
176
+ grant_type: "refresh_token",
177
+ refresh_token: options.refreshToken,
178
+ client_id: CLIENT_ID,
179
+ scope: REFRESH_SCOPE,
180
+ }),
181
+ });
182
+
183
+ if (!response.ok) {
184
+ if (response.status >= 500 && attempt < maxRetries) {
185
+ await response.body?.cancel().catch(() => {});
186
+ continue;
187
+ }
188
+ const body = await response.text().catch(() => "");
189
+ throw new OAuthRefreshError(response.status, body, response.headers.get("retry-after"));
190
+ }
191
+
192
+ const json = await response.json() as { access_token: string; refresh_token?: string; expires_in: number };
193
+ const at = options.now?.() ?? Date.now();
194
+ return {
195
+ access: json.access_token,
196
+ // Anthropic may omit a rotated refresh token; keep the existing one.
197
+ refresh: json.refresh_token ?? options.refreshToken,
198
+ expires: at + json.expires_in * 1000,
199
+ };
200
+ } catch (error) {
201
+ if (error instanceof OAuthRefreshError) throw error;
202
+ if (attempt < maxRetries && isTransientNetworkError(error)) continue;
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ throw new Error("Token refresh exhausted all retries");
208
+ }