@adeildo/pi-providers 4.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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +51 -0
- package/src/index.ts +10 -0
- package/src/subscription/billing.ts +27 -0
- package/src/subscription/feature.ts +43 -0
- package/src/subscription/payload.ts +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 adeildo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @adeildo/pi-providers
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@adeildo/pi-providers)
|
|
4
|
+
|
|
5
|
+
Provider features for [Pi](https://pi.dev). It has one, `subscription`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@adeildo/pi-providers
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
|
|
12
|
+
|
|
13
|
+
## subscription
|
|
14
|
+
|
|
15
|
+
With a Claude Pro or Max subscription connected through `/login anthropic`, Pi's requests count as paid extra usage. With `subscription`, they come out of the plan, the way Claude Code's do.
|
|
16
|
+
|
|
17
|
+
> [!WARNING]
|
|
18
|
+
> This makes Pi introduce itself to Anthropic as Claude Code. Using your subscription from anything other than Claude Code may break Anthropic's terms. It is on once the package is installed, so decide whether you're fine with that risk. To turn it off, switch off the extension in `pi config`, or set `"features": { "subscription": { "enabled": false } }` in the settings file below.
|
|
19
|
+
|
|
20
|
+
Pi already sends Claude Code's identity with an OAuth token. Anthropic checks two more things before it bills a request to the plan.
|
|
21
|
+
|
|
22
|
+
1. The user agent. It has to read `claude-cli/<version> (external, cli)`.
|
|
23
|
+
2. The `system` blocks. The first one has to be a billing attribution line, and there can't be anything Claude Code wouldn't send. So pi's own prompt moves to the start of the first user message, wrapped in `<system-instructions>`. The model still reads it there and follows it.
|
|
24
|
+
|
|
25
|
+
It acts on any provider that uses the Anthropic Messages API with an OAuth token, not only the one called `anthropic`, so a second account registered under another name is billed to its own plan. Requests with an API key go out untouched.
|
|
26
|
+
|
|
27
|
+
Pi warns about extra usage on every Anthropic OAuth session. With the requests billed to the plan, turn the warning off in `~/.pi/agent/settings.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{ "warnings": { "anthropicExtraUsage": false } }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Settings
|
|
34
|
+
|
|
35
|
+
In `~/.pi/agent/extensions/pi-harness/settings.json`:
|
|
36
|
+
|
|
37
|
+
| Key | Default | Does |
|
|
38
|
+
| -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- |
|
|
39
|
+
| `subscription.claudeCodeVersion` | `"2.1.280"` | The Claude Code version Pi reports. Anthropic won't serve newer models to versions it considers too old |
|
|
40
|
+
|
|
41
|
+
When a request comes back with `400 claude_code_version_too_old`, raise the version:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "subscription": { "claudeCodeVersion": "2.1.280" } }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Credit
|
|
48
|
+
|
|
49
|
+
The idea and the fingerprint come from [pi-claude-max](https://github.com/bradennss/pi-claude-max) by Braden Lamb, MIT. This is a rewrite. It reads the version from settings, carries pi's cache breakpoints over instead of dropping the one-hour TTL, moves every system block after the identity instead of only the second, and checks for an OAuth token instead of the provider name.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adeildo/pi-providers",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Provider features for Pi. Bills Anthropic OAuth requests to the Claude Pro or Max plan instead of extra usage.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"anthropic",
|
|
7
|
+
"claude",
|
|
8
|
+
"pi",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-package",
|
|
12
|
+
"subscription"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/felipeadeildo/pi-harness/tree/main/packages/providers#readme",
|
|
15
|
+
"bugs": "https://github.com/felipeadeildo/pi-harness/issues",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "adeildo",
|
|
19
|
+
"email": "contato@felipeadeildo.com",
|
|
20
|
+
"url": "https://github.com/felipeadeildo"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/felipeadeildo/pi-harness.git",
|
|
25
|
+
"directory": "packages/providers"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@adeildo/pi-kit": "4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
45
|
+
},
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./src/index.ts"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createApp } from "@adeildo/pi-kit";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { subscription } from "./subscription/feature.ts";
|
|
5
|
+
|
|
6
|
+
export { claudeCodeVersion, subscription } from "./subscription/feature.ts";
|
|
7
|
+
|
|
8
|
+
export default function piProviders(pi: ExtensionAPI): void {
|
|
9
|
+
createApp(pi, { name: "pi-providers" }).use(subscription).build();
|
|
10
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// How Claude Code tells Anthropic who it is. A request is billed to the Claude plan when it
|
|
2
|
+
// carries Claude Code's user agent and a first system block with a billing attribution, which ends
|
|
3
|
+
// in three characters of the first user message hashed with the version and a salt.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
7
|
+
|
|
8
|
+
const FINGERPRINT_SALT = "59cf53e54c78";
|
|
9
|
+
const SAMPLED_POSITIONS = [4, 7, 20];
|
|
10
|
+
|
|
11
|
+
export function userAgent(version: string): string {
|
|
12
|
+
return `claude-cli/${version} (external, cli)`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Positions past the end of the message count as "0", the way Claude Code pads them. */
|
|
16
|
+
export function fingerprint(firstUserText: string, version: string): string {
|
|
17
|
+
const sampled = SAMPLED_POSITIONS.map((position) => firstUserText[position] ?? "0").join("");
|
|
18
|
+
return createHash("sha256")
|
|
19
|
+
.update(`${FINGERPRINT_SALT}${sampled}${version}`)
|
|
20
|
+
.digest("hex")
|
|
21
|
+
.slice(0, 3);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function billingAttribution(firstUserText: string, version: string): string {
|
|
25
|
+
const stamp = `${version}.${fingerprint(firstUserText, version)}`;
|
|
26
|
+
return `x-anthropic-billing-header: cc_version=${stamp}; cc_entrypoint=cli;`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Bills Anthropic OAuth requests to the Claude Pro/Max plan instead of extra usage. It acts on any
|
|
2
|
+
// provider that speaks the Anthropic Messages API with an OAuth token, not only the one named
|
|
3
|
+
// `anthropic`, so a second account under another name gets the same treatment.
|
|
4
|
+
import { defineFeature, isObject, matching, setting } from "@adeildo/pi-kit";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { userAgent } from "./billing.ts";
|
|
8
|
+
import { billToPlan } from "./payload.ts";
|
|
9
|
+
|
|
10
|
+
export const claudeCodeVersion = setting({
|
|
11
|
+
id: "subscription.claudeCodeVersion",
|
|
12
|
+
default: "2.1.280",
|
|
13
|
+
decoder: matching(/^\d+\.\d+\.\d+$/, "a Claude Code version like 2.1.280"),
|
|
14
|
+
ui: {
|
|
15
|
+
group: "Providers",
|
|
16
|
+
label: "Claude Code version",
|
|
17
|
+
description:
|
|
18
|
+
"The Claude Code version pi reports to Anthropic. Anthropic refuses newer models to versions it considers too old.",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const subscription = defineFeature({
|
|
23
|
+
id: "subscription",
|
|
24
|
+
description: "Bill Anthropic OAuth requests to the Claude plan instead of extra usage",
|
|
25
|
+
settings: [claudeCodeVersion],
|
|
26
|
+
setup(scope) {
|
|
27
|
+
scope.on("before_provider_headers", (event, ctx) => {
|
|
28
|
+
if (usesSubscription(ctx))
|
|
29
|
+
event.headers["user-agent"] = userAgent(claudeCodeVersion.get(scope));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
scope.on("before_provider_request", (event, ctx) => {
|
|
33
|
+
if (!usesSubscription(ctx) || !isObject(event.payload)) return undefined;
|
|
34
|
+
return billToPlan(event.payload, claudeCodeVersion.get(scope));
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function usesSubscription(ctx: ExtensionContext): boolean {
|
|
40
|
+
const model = ctx.model;
|
|
41
|
+
if (model === undefined || model.api !== "anthropic-messages") return false;
|
|
42
|
+
return ctx.modelRegistry.isUsingOAuth(model);
|
|
43
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Rewrites the request pi builds for an Anthropic OAuth token. Anthropic's billing classifier reads
|
|
2
|
+
// `system`, and any text there that Claude Code would not send costs extra usage. So `system` keeps
|
|
3
|
+
// the attribution and the identity, and pi's prompt moves into the first user message, where the
|
|
4
|
+
// model still follows it.
|
|
5
|
+
import { isObject } from "@adeildo/pi-kit";
|
|
6
|
+
|
|
7
|
+
import { billingAttribution, CLAUDE_CODE_IDENTITY } from "./billing.ts";
|
|
8
|
+
|
|
9
|
+
export interface TextBlock {
|
|
10
|
+
type: "text";
|
|
11
|
+
text: string;
|
|
12
|
+
cache_control?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Payload = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
export function billToPlan(payload: Payload, version: string): Payload | undefined {
|
|
18
|
+
const system = claudeCodeSystem(payload.system);
|
|
19
|
+
if (system === undefined) return undefined;
|
|
20
|
+
|
|
21
|
+
const [identity, ...moved] = system;
|
|
22
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
23
|
+
const attribution: TextBlock = {
|
|
24
|
+
type: "text",
|
|
25
|
+
text: billingAttribution(firstUserText(messages), version),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...payload,
|
|
30
|
+
system: [attribution, identity],
|
|
31
|
+
messages: prependInstructions(messages, moved),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function claudeCodeSystem(system: unknown): [TextBlock, ...TextBlock[]] | undefined {
|
|
36
|
+
if (!Array.isArray(system) || !system.every(isTextBlock)) return undefined;
|
|
37
|
+
const [first, ...rest] = system;
|
|
38
|
+
if (first === undefined || !first.text.startsWith(CLAUDE_CODE_IDENTITY)) return undefined;
|
|
39
|
+
return [first, ...rest];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isTextBlock(block: unknown): block is TextBlock {
|
|
43
|
+
return isObject(block) && block.type === "text" && typeof block.text === "string";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function firstUserText(messages: readonly unknown[]): string {
|
|
47
|
+
const message = messages.find(isUserMessage);
|
|
48
|
+
if (message === undefined) return "";
|
|
49
|
+
if (typeof message.content === "string") return message.content;
|
|
50
|
+
if (!Array.isArray(message.content)) return "";
|
|
51
|
+
return message.content.find(isTextBlock)?.text ?? "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isUserMessage(message: unknown): message is Payload {
|
|
55
|
+
return isObject(message) && message.role === "user";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function prependInstructions(messages: readonly unknown[], moved: readonly TextBlock[]): unknown[] {
|
|
59
|
+
const text = moved
|
|
60
|
+
.map((block) => block.text)
|
|
61
|
+
.filter((part) => part.trim() !== "")
|
|
62
|
+
.join("\n\n");
|
|
63
|
+
if (text === "") return [...messages];
|
|
64
|
+
|
|
65
|
+
// The moved prompt takes over the cache breakpoint pi put on it, so the request keeps the same
|
|
66
|
+
// number of breakpoints and the prompt stays cached on its own.
|
|
67
|
+
const cacheControl = moved.findLast((block) => block.cache_control !== undefined)?.cache_control;
|
|
68
|
+
const instructions: TextBlock = {
|
|
69
|
+
type: "text",
|
|
70
|
+
text: `<system-instructions>\n${text}\n</system-instructions>`,
|
|
71
|
+
...(cacheControl === undefined ? {} : { cache_control: cacheControl }),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const index = messages.findIndex(isUserMessage);
|
|
75
|
+
const target = messages[index];
|
|
76
|
+
if (!isUserMessage(target)) return [{ role: "user", content: [instructions] }, ...messages];
|
|
77
|
+
|
|
78
|
+
const updated = [...messages];
|
|
79
|
+
updated[index] = { ...target, content: [instructions, ...contentBlocks(target.content)] };
|
|
80
|
+
return updated;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contentBlocks(content: unknown): unknown[] {
|
|
84
|
+
if (Array.isArray(content)) return content;
|
|
85
|
+
if (typeof content === "string" && content !== "") return [{ type: "text", text: content }];
|
|
86
|
+
return [];
|
|
87
|
+
}
|