@jameslovespancakes/pi-plus 1.0.0 → 1.0.1
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 -21
- package/README.md +190 -190
- package/config/pi-plus.example.json +60 -60
- package/config/skills/model-routing/SKILL.md +86 -86
- package/images/pi-plus.svg +10 -10
- package/package.json +67 -67
- package/server/board-server.mjs +641 -641
- package/server/package.json +17 -17
- package/src/core/accounts/registry.ts +93 -93
- package/src/core/anthropic/client-identity.ts +241 -241
- package/src/core/catalog/quality.ts +314 -314
- package/src/core/config.ts +169 -169
- package/src/core/env.ts +58 -58
- package/src/core/exec/process.ts +146 -146
- package/src/core/exec/ssh-config.ts +157 -157
- package/src/core/policy/policy.ts +183 -183
- package/src/core/quota/pool.ts +64 -64
- package/src/core/quota/usage-source.ts +289 -289
- package/src/core/store.ts +43 -43
- package/src/domains/agents/board-setup.ts +409 -409
- package/src/domains/agents/index.ts +462 -462
- package/src/domains/models/catalog-tool.ts +361 -361
- package/src/domains/models/index.ts +14 -14
- package/src/domains/models/policy-gate.ts +169 -169
- package/src/domains/models/provider-picker.ts +207 -207
- package/src/domains/remote/config-path.ts +41 -41
- package/src/domains/remote/index.ts +866 -866
- package/src/domains/remote/setup.ts +425 -425
- package/src/domains/setup/index.ts +220 -220
- package/src/domains/subscriptions/accounts.ts +242 -242
- package/src/domains/subscriptions/footer.ts +182 -182
- package/src/domains/subscriptions/index.ts +42 -42
- package/src/domains/subscriptions/provider.ts +219 -219
- package/src/domains/subscriptions/providers/anthropic.ts +149 -149
- package/src/domains/subscriptions/providers/codex.ts +148 -148
- package/src/domains/subscriptions/routing.ts +72 -72
- package/src/services/usage-service.ts +186 -186
- package/src/ui/format.ts +73 -73
- package/src/ui/usage-bars.ts +154 -154
- package/src/vendor/anthropic.ts +109 -109
|
@@ -1,241 +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
|
-
}
|
|
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
|
+
}
|