@justjoshn/pi-anthropic-oauth-v2 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Adds `/claude-usage`, which shows how much of each Claude subscription limit you have used and when it resets.
6
+
7
+ ## 0.1.0
8
+
9
+ - First release. Registers `anthropic-oauth-v2`, a provider that uses only Claude subscription OAuth and is built on Pi's own Anthropic login, Messages API, and model catalog.
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # pi-anthropic-oauth-v2
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This Pi extension lets you use a Claude subscription in Pi. It authenticates only with the subscription OAuth token from your claude.ai login. Pro, Max, Ultra, and Team (Standard or Premium) logins all work the same way.
4
+
5
+ It registers the provider `anthropic-oauth-v2`, built from Pi's own parts. Pi supplies them to extensions through `@earendil-works/pi-ai/providers/all`:
6
+
7
+ - **Login and refresh:** Pi's built-in Anthropic OAuth flow, with browser or copy-code login. Pi's credential store refreshes the token under its lock and saves it in `~/.pi/agent/auth.json` under `anthropic-oauth-v2`.
8
+ - **Requests:** Pi's Anthropic Messages implementation. It sends an OAuth token as a Bearer credential, with the Claude Code identity that Anthropic requires for subscription tokens.
9
+ - **Models:** Pi's built-in Anthropic catalog, listed under `anthropic-oauth-v2/`.
10
+
11
+ The extension adds only the OAuth-only check below. Pi's built-in `anthropic` provider is unchanged.
12
+
13
+ ## OAuth only
14
+
15
+ The provider has no API-key handler, so Pi never reads `ANTHROPIC_API_KEY`, `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, workload identity federation, a stored `api_key` credential, or an API-key override for it. Pi still layers `models.json` settings (`apiKey`, `headers`, `modelOverrides`) onto every provider, so the provider checks the final request just before it is sent. It refuses the request unless the credential is an `sk-ant-oat` access token, no pre-built SDK client is passed in, and every added header is `anthropic-beta` or `anthropic-version` with no `sk-ant-` key in its value. That covers headers set through Pi's `models.json` `headers` setting. Pi itself adds no headers on this path. The provider also refuses every request while `ANTHROPIC_CUSTOM_HEADERS` is set, because the Anthropic SDK reads that variable on its own and Pi does not document it. `test/oauth-only.test.ts` and `scripts/prove.sh` check this, including against a local server that records what actually arrives.
16
+
17
+ ## Replaces the old Claude login
18
+
19
+ This extension replaces `@justjoshn/pi-anthropic-oauth` (provider `claude-subscription`) and Pi's built-in `anthropic` login. Keep only one Claude login. If two Claude providers are logged in at once, they offer the same model ids, so Pi and pi-pstack reject bare model names like `sonnet` or `claude-opus-5-5` as ambiguous. Remove the others first:
20
+
21
+ ```bash
22
+ pi remove npm:@justjoshn/pi-anthropic-oauth
23
+ pi auth logout claude-subscription
24
+ pi auth logout anthropic
25
+ ```
26
+
27
+ `scripts/prove.sh` checks that bare model names reach `anthropic-oauth-v2` when it is the only Claude login.
28
+
29
+ ## Install and use
30
+
31
+ Install the package and start Pi:
32
+
33
+ ```bash
34
+ pi install npm:@justjoshn/pi-anthropic-oauth-v2
35
+ pi
36
+ ```
37
+
38
+ To try it from this checkout, use `pi -e extensions/pi-anthropic-oauth-v2`.
39
+
40
+ Run `/login`, choose **Anthropic (Claude subscription, OAuth)**, and sign in with your claude.ai account. Then pick an `anthropic-oauth-v2/...` model with `/model`.
41
+
42
+ This needs Pi 1.5.2 or later.
43
+
44
+ ## Check your usage
45
+
46
+ Run `/claude-usage` to see how much of your plan's limits you have used. Pi has no built-in usage check, so the extension adds this command. It reads your stored login through Pi, asks Anthropic's subscription usage endpoint (`GET https://api.anthropic.com/api/oauth/usage`), and shows one line per limit:
47
+
48
+ ```text
49
+ Current session: 19% used, resets in 15m
50
+ Current week (all models): 78% used, resets in 1d 15h 55m
51
+ Current week (Fable): 0% used, resets in 1d 15h 55m
52
+ ```
53
+
54
+ The rows, their order, and the model names come from Anthropic, so a new limit shows up without an extension update. The notice turns into a warning when Anthropic marks any limit above normal. With no login, or with a credential that is not a subscription OAuth token, the command sends nothing and asks you to run `/login`. In print mode (`pi -p "/claude-usage"`) the lines go to stderr.
55
+
56
+ The command is `/claude-usage`, not `/usage`, because `pi-x-subscription-oauth` already registers `/usage`. Pi renames clashing commands to `/usage:1` and `/usage:2`.
57
+
58
+ ## Check it
59
+
60
+ ```bash
61
+ bun run test # OAuth-only rule through pi-ai's real auth and stream paths
62
+ scripts/prove.sh # real pi: registration, OAuth-only refusals, leanness, /claude-usage without a login
63
+ scripts/prove.sh --live # also a real /claude-usage, a real request, a tool call, and a forced token refresh (needs /login first)
64
+ ```
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@justjoshn/pi-anthropic-oauth-v2",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Use your Claude subscription in Pi through subscription OAuth only, built on Pi's own Anthropic login and Messages API.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/just-joshn/pi-customizations.git",
13
+ "directory": "extensions/pi-anthropic-oauth-v2"
14
+ },
15
+ "keywords": [
16
+ "pi-package"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "test:coverage": "vitest run --coverage",
34
+ "typecheck": "tsc -p .",
35
+ "prove": "scripts/prove.sh",
36
+ "prove:live": "scripts/prove.sh --live"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-ai": "*",
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-ai": "1.5.2",
44
+ "@earendil-works/pi-coding-agent": "1.5.2",
45
+ "@types/node": "^22.19.0",
46
+ "@vitest/coverage-v8": "^5.0.2",
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^5.0.2"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { anthropicMessagesApi, type ProviderStreams } from '@earendil-works/pi-ai/compat';
2
+ import { createProvider, hasApi } from '@earendil-works/pi-ai/models';
3
+ import { builtinProviders } from '@earendil-works/pi-ai/providers/all';
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
5
+ import { createUsageCommand } from './usage.ts';
6
+
7
+ const ID = 'anthropic-oauth-v2';
8
+
9
+ type StreamArgs = Parameters<ProviderStreams['stream']>;
10
+
11
+ const ALLOWED_HEADERS = new Set(['anthropic-beta', 'anthropic-version']);
12
+ const isAllowedHeader = ([name, value]: [string, string | null]) => value === null || (ALLOWED_HEADERS.has(name.toLowerCase()) && !value.includes('sk-ant-'));
13
+
14
+ const assertOAuthOnly = (model: StreamArgs[0], options: StreamArgs[2]) => {
15
+ const headers = Object.entries({ ...model.headers, ...options?.headers });
16
+ const usesOwnClient = options !== undefined && 'client' in options;
17
+ // The Anthropic SDK also reads ANTHROPIC_CUSTOM_HEADERS into every request.
18
+ const setsSdkHeaders = Boolean(process.env['ANTHROPIC_CUSTOM_HEADERS']?.trim());
19
+ if (!options?.apiKey?.startsWith('sk-ant-oat') || usesOwnClient || setsSdkHeaders || !headers.every(isAllowedHeader)) {
20
+ throw new Error(`${ID}: refusing non-OAuth credential; run /login and remove any apiKey and any headers other than anthropic-beta or anthropic-version for ${ID} from models.json, and unset ANTHROPIC_CUSTOM_HEADERS`);
21
+ }
22
+ };
23
+
24
+ export default function (pi: Pick<ExtensionAPI, 'registerProvider' | 'registerCommand'>) {
25
+ const anthropic = builtinProviders().find((provider) => provider.id === 'anthropic');
26
+ const oauth = anthropic?.auth.oauth;
27
+ if (!anthropic || !oauth) throw new Error(`${ID}: this Pi build has no Anthropic OAuth login`);
28
+ const api = anthropicMessagesApi();
29
+
30
+ pi.registerProvider(
31
+ createProvider({
32
+ id: ID,
33
+ name: 'Anthropic (Claude subscription)',
34
+ auth: { oauth: { ...oauth, name: 'Anthropic (Claude subscription, OAuth)' } },
35
+ models: anthropic
36
+ .getModels()
37
+ .filter((model) => hasApi(model, 'anthropic-messages'))
38
+ .map((model) => ({ ...model, provider: ID, compat: { supportsMessageThreads: true, ...model.compat } })),
39
+ api: {
40
+ ...api,
41
+ stream: (model, context, options) => {
42
+ assertOAuthOnly(model, options);
43
+ return api.stream(model, context, options);
44
+ },
45
+ streamSimple: (model, context, options) => {
46
+ assertOAuthOnly(model, options);
47
+ return api.streamSimple(model, context, options);
48
+ },
49
+ },
50
+ }),
51
+ );
52
+ pi.registerCommand('claude-usage', createUsageCommand(ID));
53
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+
3
+ type UsageRow = { title: string; percent: number; resetsAt: unknown; severity: unknown };
4
+
5
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
6
+ const RELOGIN = 'Run /login and choose Anthropic (Claude subscription, OAuth).';
7
+ const GROUP_TITLES: Record<string, string> = { session: 'Current session', weekly: 'Current week' };
8
+ // Servers that predate limits[] send only these windows.
9
+ const WINDOWS = [
10
+ ['five_hour', 'Current session'],
11
+ ['seven_day', 'Current week (all models)'],
12
+ ['seven_day_sonnet', 'Current week (Sonnet)'],
13
+ ['seven_day_opus', 'Current week (Opus)'],
14
+ ] as const;
15
+
16
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ const displayName = (value: unknown) => (isRecord(value) && typeof value['display_name'] === 'string' ? value['display_name'] : undefined);
18
+
19
+ function limitRow(limit: unknown): UsageRow[] {
20
+ if (!isRecord(limit) || typeof limit['percent'] !== 'number') return [];
21
+ const group = String(limit['group']);
22
+ const base = GROUP_TITLES[group] ?? group;
23
+ const scope = isRecord(limit['scope']) ? (displayName(limit['scope']['model']) ?? displayName(limit['scope']['surface'])) : undefined;
24
+ const title = scope ? `${base} (${scope})` : limit['kind'] === 'weekly_all' ? `${base} (all models)` : base;
25
+ return [{ title, percent: limit['percent'], resetsAt: limit['resets_at'], severity: limit['severity'] }];
26
+ }
27
+
28
+ function parseUsage(body: unknown): UsageRow[] | undefined {
29
+ if (!isRecord(body)) return undefined;
30
+ const limits = body['limits'];
31
+ if (Array.isArray(limits)) return limits.flatMap(limitRow);
32
+ if (limits !== undefined && limits !== null) return undefined;
33
+ return WINDOWS.flatMap(([key, title]) => {
34
+ const window = body[key];
35
+ return isRecord(window) && typeof window['utilization'] === 'number' ? [{ title, percent: window['utilization'], resetsAt: window['resets_at'], severity: 'normal' }] : [];
36
+ });
37
+ }
38
+
39
+ function resetsIn(resetsAt: unknown): string {
40
+ const at = typeof resetsAt === 'string' ? Date.parse(resetsAt) : Number.NaN;
41
+ if (Number.isNaN(at)) return '';
42
+ const minutes = Math.max(0, Math.round((at - Date.now()) / 60_000));
43
+ const days = Math.floor(minutes / 1_440);
44
+ const hours = Math.floor((minutes % 1_440) / 60);
45
+ const left = days > 0 ? `${days}d ${hours}h ${minutes % 60}m` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
46
+ return `, resets in ${left}`;
47
+ }
48
+
49
+ const formatRow = (row: UsageRow) => `${row.title}: ${Number(row.percent.toFixed(1))}% used${resetsIn(row.resetsAt)}`;
50
+
51
+ async function fetchUsage(token: string): Promise<UsageRow[]> {
52
+ const response = await fetch(USAGE_URL, {
53
+ headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' },
54
+ signal: AbortSignal.timeout(30_000),
55
+ });
56
+ if (response.status === 401 || response.status === 403) throw new Error(`Claude rejected the login (HTTP ${response.status}). ${RELOGIN}`);
57
+ if (!response.ok) throw new Error(`Claude usage check failed (HTTP ${response.status}).`);
58
+ const rows = parseUsage(await response.json().catch(() => undefined));
59
+ if (!rows) throw new Error('Claude returned usage Pi cannot read.');
60
+ return rows;
61
+ }
62
+
63
+ function report(ctx: ExtensionCommandContext, text: string, level: 'info' | 'warning' | 'error') {
64
+ if (ctx.hasUI) ctx.ui.notify(text, level);
65
+ else process.stderr.write(`${text}\n`);
66
+ }
67
+
68
+ export function createUsageCommand(providerId: string): Parameters<ExtensionAPI['registerCommand']>[1] {
69
+ return {
70
+ description: 'Show Claude subscription usage limits',
71
+ handler: async (_args, ctx) => {
72
+ try {
73
+ const token = (await ctx.modelRegistry.getProviderAuth(providerId))?.auth.apiKey;
74
+ if (!token?.startsWith('sk-ant-oat')) return report(ctx, `Claude is not logged in. ${RELOGIN}`, 'error');
75
+ const rows = await fetchUsage(token);
76
+ if (rows.length === 0) return report(ctx, 'Claude reported no usage limits for this plan.', 'info');
77
+ const level = rows.some((row) => row.severity !== 'normal') ? 'warning' : 'info';
78
+ report(ctx, rows.map(formatRow).join('\n'), level);
79
+ } catch (error) {
80
+ report(ctx, error instanceof Error ? error.message : String(error), 'error');
81
+ }
82
+ },
83
+ };
84
+ }