pi-anthropic-oauth 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo Henon
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,35 @@
1
+ # Pi Anthropic OAuth
2
+
3
+ [![npm](https://img.shields.io/npm/v/pi-anthropic-oauth?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/pi-anthropic-oauth) [![node](https://img.shields.io/badge/node-%3E%3D18-7c3aed?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
4
+
5
+ Claude Pro/Max OAuth extension for Pi.
6
+
7
+ > [!WARNING]
8
+ > Use this at your own risk. This may go against Anthropic's terms.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:pi-anthropic-oauth
14
+ ```
15
+
16
+ Start Pi, then run:
17
+
18
+ ```text
19
+ /login anthropic
20
+ ```
21
+
22
+ Choose:
23
+
24
+ ```text
25
+ Claude Pro/Max
26
+ ```
27
+
28
+ ## Notes
29
+
30
+ - sends Claude Code-like OAuth headers
31
+ - rewrites Pi system identity where needed
32
+
33
+ ## License
34
+
35
+ MIT
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-anthropic-oauth",
3
+ "version": "0.1.0",
4
+ "description": "Claude Pro/Max OAuth extension for Pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "anthropic",
11
+ "claude",
12
+ "oauth",
13
+ "extension"
14
+ ],
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/leohenon/pi-anthropic-oauth"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/leohenon/pi-anthropic-oauth/issues"
29
+ },
30
+ "homepage": "https://github.com/leohenon/pi-anthropic-oauth#readme",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "check": "tsc -p tsconfig.json --noEmit",
36
+ "pack:check": "npm pack --dry-run",
37
+ "prepublishOnly": "npm run check && npm run pack:check"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "dependencies": {
45
+ "@anthropic-ai/sdk": "^0.52.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@mariozechner/pi-ai": "*",
49
+ "@mariozechner/pi-coding-agent": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@mariozechner/pi-ai": "^0.66.1",
53
+ "@mariozechner/pi-coding-agent": "^0.66.1",
54
+ "typescript": "^5.9.3"
55
+ }
56
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { createServer } from "node:http";
2
+ import type {
3
+ OAuthCredentials,
4
+ OAuthLoginCallbacks,
5
+ } from "@mariozechner/pi-ai";
6
+
7
+ const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
8
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
9
+ const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
10
+ const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
11
+ const SCOPES = [
12
+ "org:create_api_key",
13
+ "user:profile",
14
+ "user:inference",
15
+ "user:sessions:claude_code",
16
+ "user:mcp_servers",
17
+ "user:file_upload",
18
+ ].join(" ");
19
+ const USER_AGENT = "claude-code/2.1.96";
20
+ const LOCAL_CALLBACK_TIMEOUT = 5 * 60 * 1000;
21
+
22
+ export { USER_AGENT };
23
+
24
+ type ParsedAuthInput = { code: string; state: string };
25
+ type LocalAuthorization = {
26
+ redirectUri: string;
27
+ waitForCallback: () => Promise<string | null>;
28
+ };
29
+
30
+ export function isClaudeOAuthAccessToken(apiKey: string): boolean {
31
+ return apiKey.includes("sk-ant-oat");
32
+ }
33
+
34
+ export async function loginAnthropic(
35
+ callbacks: OAuthLoginCallbacks,
36
+ ): Promise<OAuthCredentials> {
37
+ const { verifier, challenge } = await generatePKCE();
38
+ const state = crypto.randomUUID().replace(/-/g, "");
39
+
40
+ let authInput: string | null = null;
41
+ let redirectUri = REDIRECT_URI;
42
+
43
+ try {
44
+ const localAuthorization = await createLocalAuthorization(state);
45
+ redirectUri = localAuthorization.redirectUri;
46
+
47
+ callbacks.onAuth({
48
+ url: makeAuthorizeUrl(challenge, state, redirectUri),
49
+ instructions:
50
+ "Complete authorization in the browser. Pi will try to capture the callback automatically.",
51
+ });
52
+
53
+ authInput = await localAuthorization.waitForCallback();
54
+ } catch {
55
+ // Fall back to manual code entry when localhost callback setup fails.
56
+ }
57
+
58
+ if (!authInput) {
59
+ redirectUri = REDIRECT_URI;
60
+ callbacks.onAuth({
61
+ url: makeAuthorizeUrl(challenge, state, redirectUri),
62
+ instructions:
63
+ "Sign in with Claude, then paste the full callback URL or the code#state value.",
64
+ });
65
+ authInput = await callbacks.onPrompt({
66
+ message: "Paste the callback URL or code#state:",
67
+ });
68
+ }
69
+
70
+ const parsed = parseAuthInput(authInput);
71
+ if (!parsed) throw new Error("Could not parse authorization callback input.");
72
+ if (parsed.state !== state) throw new Error("OAuth state mismatch.");
73
+
74
+ const tokenResponse = await fetch(TOKEN_URL, {
75
+ method: "POST",
76
+ headers: makeTokenHeaders(),
77
+ body: new URLSearchParams({
78
+ grant_type: "authorization_code",
79
+ client_id: CLIENT_ID,
80
+ code: parsed.code,
81
+ state: parsed.state,
82
+ redirect_uri: redirectUri,
83
+ code_verifier: verifier,
84
+ }).toString(),
85
+ signal: callbacks.signal,
86
+ });
87
+
88
+ if (!tokenResponse.ok) {
89
+ throw new Error(
90
+ `Token exchange failed: ${tokenResponse.status} ${await tokenResponse.text()}`,
91
+ );
92
+ }
93
+
94
+ const data = (await tokenResponse.json()) as {
95
+ access_token: string;
96
+ refresh_token: string;
97
+ expires_in: number;
98
+ };
99
+
100
+ return {
101
+ access: data.access_token,
102
+ refresh: data.refresh_token,
103
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
104
+ };
105
+ }
106
+
107
+ export async function refreshAnthropicToken(
108
+ credentials: OAuthCredentials,
109
+ ): Promise<OAuthCredentials> {
110
+ const response = await fetch(TOKEN_URL, {
111
+ method: "POST",
112
+ headers: makeTokenHeaders(),
113
+ body: new URLSearchParams({
114
+ grant_type: "refresh_token",
115
+ client_id: CLIENT_ID,
116
+ refresh_token: credentials.refresh,
117
+ }).toString(),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ throw new Error(
122
+ `Token refresh failed: ${response.status} ${await response.text()}`,
123
+ );
124
+ }
125
+
126
+ const data = (await response.json()) as {
127
+ access_token: string;
128
+ refresh_token: string;
129
+ expires_in: number;
130
+ };
131
+
132
+ return {
133
+ access: data.access_token,
134
+ refresh: data.refresh_token || credentials.refresh,
135
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
136
+ };
137
+ }
138
+
139
+ function makeAuthorizeUrl(
140
+ challenge: string,
141
+ state: string,
142
+ redirectUri: string,
143
+ ): string {
144
+ const authParams = new URLSearchParams({
145
+ code: "true",
146
+ client_id: CLIENT_ID,
147
+ response_type: "code",
148
+ redirect_uri: redirectUri,
149
+ scope: SCOPES,
150
+ code_challenge: challenge,
151
+ code_challenge_method: "S256",
152
+ state,
153
+ });
154
+
155
+ return `${AUTHORIZE_URL}?${authParams.toString()}`;
156
+ }
157
+
158
+ function makeTokenHeaders(): HeadersInit {
159
+ return {
160
+ Accept: "application/json, text/plain, */*",
161
+ "Content-Type": "application/x-www-form-urlencoded",
162
+ "User-Agent": USER_AGENT,
163
+ };
164
+ }
165
+
166
+ async function createLocalAuthorization(
167
+ state: string,
168
+ ): Promise<LocalAuthorization> {
169
+ const server = createServer();
170
+
171
+ return new Promise((resolve, reject) => {
172
+ let done = false;
173
+ let timer: ReturnType<typeof setTimeout> | undefined;
174
+ let complete!: (value: string | null) => void;
175
+ const wait = new Promise<string | null>((innerResolve) => {
176
+ complete = innerResolve;
177
+ });
178
+
179
+ const finish = (value: string | null) => {
180
+ if (done) return;
181
+ done = true;
182
+ if (timer) clearTimeout(timer);
183
+ if (server.listening) {
184
+ server.close(() => complete(value));
185
+ return;
186
+ }
187
+ complete(value);
188
+ };
189
+
190
+ server.on("request", (req, res) => {
191
+ const url = new URL(
192
+ req.url ?? "/",
193
+ `http://${req.headers.host ?? "localhost"}`,
194
+ );
195
+
196
+ if (url.pathname !== "/callback") {
197
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
198
+ res.end("Not found");
199
+ return;
200
+ }
201
+
202
+ const code = url.searchParams.get("code");
203
+ const gotState = url.searchParams.get("state");
204
+ if (!code || !gotState) {
205
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
206
+ res.end("Missing code or state");
207
+ return;
208
+ }
209
+
210
+ if (gotState !== state) {
211
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
212
+ res.end("Invalid state");
213
+ finish(null);
214
+ return;
215
+ }
216
+
217
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
218
+ res.end(makeCallbackPage());
219
+ finish(url.toString());
220
+ });
221
+
222
+ server.once("error", reject);
223
+
224
+ server.listen(0, "127.0.0.1", () => {
225
+ const address = server.address();
226
+ if (!address || typeof address === "string") {
227
+ reject(new Error("Failed to allocate localhost callback port"));
228
+ return;
229
+ }
230
+
231
+ timer = setTimeout(() => finish(null), LOCAL_CALLBACK_TIMEOUT);
232
+ resolve({
233
+ redirectUri: `http://localhost:${address.port}/callback`,
234
+ waitForCallback: () => wait,
235
+ });
236
+ });
237
+ });
238
+ }
239
+
240
+ function makeCallbackPage(): string {
241
+ return `<!doctype html>
242
+ <html>
243
+ <head><meta charset="utf-8" /><title>Authorization complete</title></head>
244
+ <body>
245
+ <h1>Authorization complete</h1>
246
+ <p>You can close this window and return to Pi.</p>
247
+ </body>
248
+ </html>`;
249
+ }
250
+
251
+ async function generatePKCE(): Promise<{
252
+ verifier: string;
253
+ challenge: string;
254
+ }> {
255
+ const bytes = new Uint8Array(32);
256
+ crypto.getRandomValues(bytes);
257
+ const verifier = toBase64Url(bytes);
258
+ const digest = await crypto.subtle.digest(
259
+ "SHA-256",
260
+ new TextEncoder().encode(verifier),
261
+ );
262
+ return {
263
+ verifier,
264
+ challenge: toBase64Url(new Uint8Array(digest)),
265
+ };
266
+ }
267
+
268
+ function toBase64Url(bytes: Uint8Array): string {
269
+ return Buffer.from(bytes)
270
+ .toString("base64")
271
+ .replace(/\+/g, "-")
272
+ .replace(/\//g, "_")
273
+ .replace(/=+$/g, "");
274
+ }
275
+
276
+ function parseAuthInput(input: string): ParsedAuthInput | null {
277
+ const text = input.trim();
278
+
279
+ try {
280
+ const url = new URL(text);
281
+ const code = url.searchParams.get("code");
282
+ const state = url.searchParams.get("state");
283
+ if (code && state) return { code, state };
284
+ } catch {}
285
+
286
+ const split = text.split("#");
287
+ if (split.length === 2 && split[0] && split[1]) {
288
+ return { code: split[0], state: split[1] };
289
+ }
290
+
291
+ const params = new URLSearchParams(text);
292
+ const code = params.get("code");
293
+ const state = params.get("state");
294
+ return code && state ? { code, state } : null;
295
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type { ContentBlockParam, MessageParam, ToolUnion } from "@anthropic-ai/sdk/resources/messages.js";
2
+
3
+ type ToolResultContentBlock =
4
+ | { type: "text"; text: string }
5
+ | {
6
+ type: "image";
7
+ source: {
8
+ type: "base64";
9
+ media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
10
+ data: string;
11
+ };
12
+ };
13
+ import type {
14
+ ImageContent,
15
+ Message,
16
+ TextContent,
17
+ ThinkingContent,
18
+ Tool,
19
+ ToolCall,
20
+ ToolResultMessage,
21
+ } from "@mariozechner/pi-ai";
22
+ import { sanitizeSurrogates } from "./prompt.js";
23
+
24
+ export type IndexedBlock =
25
+ | (TextContent & { index: number })
26
+ | (ThinkingContent & { index: number; thinkingSignature?: string })
27
+ | (ToolCall & { index: number; partialJson: string });
28
+
29
+ const claudeCodeTools = [
30
+ "Read",
31
+ "Write",
32
+ "Edit",
33
+ "Bash",
34
+ "Grep",
35
+ "Glob",
36
+ "AskUserQuestion",
37
+ "TodoWrite",
38
+ "WebFetch",
39
+ "WebSearch",
40
+ ] as const;
41
+ const claudeCodeToolLookup = new Map(claudeCodeTools.map((name) => [name.toLowerCase(), name]));
42
+
43
+ export function toClaudeCodeToolName(name: string): string {
44
+ return claudeCodeToolLookup.get(name.toLowerCase()) ?? name;
45
+ }
46
+
47
+ export function fromClaudeCodeToolName(name: string, tools?: Tool[]): string {
48
+ const lower = name.toLowerCase();
49
+ return tools?.find((tool) => tool.name.toLowerCase() === lower)?.name ?? name;
50
+ }
51
+
52
+ export function convertPiMessagesToAnthropic(
53
+ messages: Message[],
54
+ isOAuth: boolean,
55
+ ): MessageParam[] {
56
+ const params: MessageParam[] = [];
57
+
58
+ for (let i = 0; i < messages.length; i++) {
59
+ const message = messages[i];
60
+
61
+ if (message.role === "user") {
62
+ if (typeof message.content === "string") {
63
+ if (message.content.trim()) params.push({ role: "user", content: sanitizeSurrogates(message.content) });
64
+ } else {
65
+ const blocks: ContentBlockParam[] = message.content.map((item) =>
66
+ item.type === "text"
67
+ ? { type: "text", text: sanitizeSurrogates(item.text) }
68
+ : {
69
+ type: "image",
70
+ source: { type: "base64", media_type: item.mimeType as never, data: item.data },
71
+ },
72
+ );
73
+ if (blocks.length > 0) params.push({ role: "user", content: blocks });
74
+ }
75
+ continue;
76
+ }
77
+
78
+ if (message.role === "assistant") {
79
+ const blocks: ContentBlockParam[] = [];
80
+ for (const block of message.content) {
81
+ if (block.type === "text" && block.text.trim()) {
82
+ blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
83
+ } else if (block.type === "thinking" && block.thinking.trim()) {
84
+ if (block.thinkingSignature) {
85
+ blocks.push({
86
+ type: "thinking" as never,
87
+ thinking: sanitizeSurrogates(block.thinking),
88
+ signature: block.thinkingSignature,
89
+ });
90
+ } else {
91
+ blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) });
92
+ }
93
+ } else if (block.type === "toolCall") {
94
+ blocks.push({
95
+ type: "tool_use",
96
+ id: block.id,
97
+ name: isOAuth ? toClaudeCodeToolName(block.name) : block.name,
98
+ input: block.arguments,
99
+ });
100
+ }
101
+ }
102
+ if (blocks.length > 0) params.push({ role: "assistant", content: blocks });
103
+ continue;
104
+ }
105
+
106
+ if (message.role === "toolResult") {
107
+ const toolResults = [
108
+ {
109
+ type: "tool_result" as const,
110
+ tool_use_id: message.toolCallId,
111
+ content: convertToolResultContentToAnthropic(message.content),
112
+ is_error: message.isError,
113
+ },
114
+ ];
115
+
116
+ let j = i + 1;
117
+ while (j < messages.length && messages[j]?.role === "toolResult") {
118
+ const nextMessage = messages[j] as ToolResultMessage;
119
+ toolResults.push({
120
+ type: "tool_result" as const,
121
+ tool_use_id: nextMessage.toolCallId,
122
+ content: convertToolResultContentToAnthropic(nextMessage.content),
123
+ is_error: nextMessage.isError,
124
+ });
125
+ j++;
126
+ }
127
+ i = j - 1;
128
+ params.push({ role: "user", content: toolResults });
129
+ }
130
+ }
131
+
132
+ const last = params.at(-1);
133
+ if (last?.role === "user" && Array.isArray(last.content) && last.content.length > 0) {
134
+ const lastBlock = last.content[last.content.length - 1] as { cache_control?: { type: string } };
135
+ lastBlock.cache_control = { type: "ephemeral" };
136
+ }
137
+
138
+ return params;
139
+ }
140
+
141
+ export function convertPiToolsToAnthropic(tools: Tool[], isOAuth: boolean): ToolUnion[] {
142
+ return tools.map((tool) => ({
143
+ name: isOAuth ? toClaudeCodeToolName(tool.name) : tool.name,
144
+ description: tool.description,
145
+ input_schema: {
146
+ type: "object" as const,
147
+ properties: (tool.parameters as { properties?: Record<string, unknown> }).properties ?? {},
148
+ required: (tool.parameters as { required?: string[] }).required ?? [],
149
+ },
150
+ }));
151
+ }
152
+
153
+ function convertToolResultContentToAnthropic(
154
+ content: (TextContent | ImageContent)[],
155
+ ): string | ToolResultContentBlock[] {
156
+ const hasImages = content.some((block) => block.type === "image");
157
+ if (!hasImages) {
158
+ return sanitizeSurrogates(
159
+ content
160
+ .filter((block): block is TextContent => block.type === "text")
161
+ .map((block) => block.text)
162
+ .join("\n"),
163
+ );
164
+ }
165
+
166
+ const blocks = content.map((block) => {
167
+ if (block.type === "text") return { type: "text" as const, text: sanitizeSurrogates(block.text) };
168
+ return {
169
+ type: "image" as const,
170
+ source: {
171
+ type: "base64" as const,
172
+ media_type: block.mimeType as ToolResultContentBlock extends { type: "image"; source: infer S }
173
+ ? S extends { media_type: infer M }
174
+ ? M
175
+ : never
176
+ : never,
177
+ data: block.data,
178
+ },
179
+ };
180
+ });
181
+
182
+ if (!blocks.some((block) => block.type === "text")) {
183
+ blocks.unshift({ type: "text", text: "(see attached image)" });
184
+ }
185
+
186
+ return blocks;
187
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { loginAnthropic, refreshAnthropicToken } from "./auth.js";
3
+ import { streamAnthropicOAuth } from "./stream.js";
4
+
5
+ const MODELS = [
6
+ {
7
+ id: "claude-opus-4-6",
8
+ name: "Claude Opus 4.6",
9
+ reasoning: true,
10
+ input: ["text", "image"] as ("text" | "image")[],
11
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
12
+ contextWindow: 200000,
13
+ maxTokens: 128000,
14
+ },
15
+ {
16
+ id: "claude-sonnet-4-6",
17
+ name: "Claude Sonnet 4.6",
18
+ reasoning: true,
19
+ input: ["text", "image"] as ("text" | "image")[],
20
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
21
+ contextWindow: 200000,
22
+ maxTokens: 64000,
23
+ },
24
+ {
25
+ id: "claude-haiku-4-5",
26
+ name: "Claude Haiku 4.5",
27
+ reasoning: true,
28
+ input: ["text", "image"] as ("text" | "image")[],
29
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
30
+ contextWindow: 200000,
31
+ maxTokens: 64000,
32
+ },
33
+ ];
34
+
35
+ export default function (pi: ExtensionAPI) {
36
+ pi.registerProvider("anthropic", {
37
+ baseUrl: "https://api.anthropic.com",
38
+ apiKey: "ANTHROPIC_MAX_API_KEY",
39
+ api: "anthropic-max-api",
40
+ models: [...MODELS],
41
+ oauth: {
42
+ name: "Claude Pro/Max",
43
+ login: loginAnthropic,
44
+ refreshToken: refreshAnthropicToken,
45
+ getApiKey: (credentials) => credentials.access,
46
+ },
47
+ streamSimple: streamAnthropicOAuth,
48
+ });
49
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,58 @@
1
+ const CLAUDE_CODE_IDENTITY =
2
+ "You are Claude Code, Anthropic's official CLI for Claude.";
3
+ const PI_REMOVAL_ANCHORS = [
4
+ "pi-coding-agent",
5
+ "@mariozechner/pi-coding-agent",
6
+ "badlogic/pi-mono",
7
+ ] as const;
8
+
9
+ type MessageContentBlock = {
10
+ type: string;
11
+ text?: string;
12
+ [key: string]: unknown;
13
+ };
14
+
15
+ export function sanitizeSurrogates(text: string): string {
16
+ return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
17
+ }
18
+
19
+ export function buildAnthropicSystemPrompt(
20
+ systemPrompt: string | undefined,
21
+ isOAuth: boolean,
22
+ ): MessageContentBlock[] | undefined {
23
+ const blocks: MessageContentBlock[] = [];
24
+
25
+ if (isOAuth) {
26
+ blocks.push({
27
+ type: "text",
28
+ text: CLAUDE_CODE_IDENTITY,
29
+ cache_control: { type: "ephemeral" },
30
+ });
31
+ }
32
+
33
+ const sanitized = systemPrompt ? sanitizeSystemText(systemPrompt) : "";
34
+ if (sanitized) {
35
+ blocks.push({
36
+ type: "text",
37
+ text: sanitized,
38
+ cache_control: { type: "ephemeral" },
39
+ });
40
+ }
41
+
42
+ return blocks.length > 0 ? blocks : undefined;
43
+ }
44
+
45
+ function sanitizeSystemText(text: string): string {
46
+ const paragraphs = text.split(/\n\n+/);
47
+ const filtered = paragraphs.filter((paragraph) => {
48
+ const lower = paragraph.toLowerCase();
49
+ if (lower.includes("you are pi")) return false;
50
+ return !PI_REMOVAL_ANCHORS.some((anchor) => paragraph.includes(anchor));
51
+ });
52
+
53
+ return filtered
54
+ .join("\n\n")
55
+ .replace(/\bpi\b/g, "Claude Code")
56
+ .replace(/\bPi\b/g, "Claude Code")
57
+ .trim();
58
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,358 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js";
3
+ import {
4
+ type Api,
5
+ type AssistantMessage,
6
+ type AssistantMessageEventStream,
7
+ calculateCost,
8
+ type Context,
9
+ createAssistantMessageEventStream,
10
+ type Model,
11
+ type SimpleStreamOptions,
12
+ type StopReason,
13
+ } from "@mariozechner/pi-ai";
14
+ import { USER_AGENT, isClaudeOAuthAccessToken } from "./auth.js";
15
+ import {
16
+ convertPiMessagesToAnthropic,
17
+ convertPiToolsToAnthropic,
18
+ fromClaudeCodeToolName,
19
+ type IndexedBlock,
20
+ } from "./convert.js";
21
+ import { buildAnthropicSystemPrompt } from "./prompt.js";
22
+
23
+ const REQUIRED_BETAS = [
24
+ "claude-code-20250219",
25
+ "oauth-2025-04-20",
26
+ "fine-grained-tool-streaming-2025-05-14",
27
+ "interleaved-thinking-2025-05-14",
28
+ ] as const;
29
+
30
+ function mapStopReason(reason: string | null | undefined): StopReason {
31
+ switch (reason) {
32
+ case "end_turn":
33
+ case "pause_turn":
34
+ case "stop_sequence":
35
+ return "stop";
36
+ case "max_tokens":
37
+ return "length";
38
+ case "tool_use":
39
+ return "toolUse";
40
+ default:
41
+ return "error";
42
+ }
43
+ }
44
+
45
+ function makeDefaultHeaders(
46
+ isOAuth: boolean,
47
+ options?: SimpleStreamOptions,
48
+ ): Record<string, string> {
49
+ const headers: Record<string, string> = {
50
+ accept: "application/json",
51
+ "anthropic-dangerous-direct-browser-access": "true",
52
+ };
53
+
54
+ if (isOAuth) {
55
+ headers["anthropic-beta"] = REQUIRED_BETAS.join(",");
56
+ headers["user-agent"] = USER_AGENT;
57
+ headers["x-app"] = "cli";
58
+ } else {
59
+ headers["anthropic-beta"] = [
60
+ "fine-grained-tool-streaming-2025-05-14",
61
+ "interleaved-thinking-2025-05-14",
62
+ ].join(",");
63
+ }
64
+
65
+ if (options?.headers) {
66
+ for (const [key, value] of Object.entries(options.headers))
67
+ headers[key] = value;
68
+ }
69
+
70
+ return headers;
71
+ }
72
+
73
+ export function streamAnthropicOAuth(
74
+ model: Model<Api>,
75
+ context: Context,
76
+ options?: SimpleStreamOptions,
77
+ ): AssistantMessageEventStream {
78
+ const stream = createAssistantMessageEventStream();
79
+
80
+ void (async () => {
81
+ const output: AssistantMessage = {
82
+ role: "assistant",
83
+ content: [],
84
+ api: model.api,
85
+ provider: model.provider,
86
+ model: model.id,
87
+ usage: {
88
+ input: 0,
89
+ output: 0,
90
+ cacheRead: 0,
91
+ cacheWrite: 0,
92
+ totalTokens: 0,
93
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
94
+ },
95
+ stopReason: "stop",
96
+ timestamp: Date.now(),
97
+ };
98
+
99
+ try {
100
+ const apiKey = options?.apiKey ?? "";
101
+ const isOAuth = isClaudeOAuthAccessToken(apiKey);
102
+ const defaultHeaders = makeDefaultHeaders(isOAuth, options);
103
+
104
+ if (isOAuth) defaultHeaders.authorization = `Bearer ${apiKey}`;
105
+
106
+ const client = new Anthropic({
107
+ baseURL: model.baseUrl,
108
+ apiKey: isOAuth ? undefined : apiKey,
109
+ authToken: isOAuth ? apiKey : undefined,
110
+ defaultHeaders,
111
+ dangerouslyAllowBrowser: true,
112
+ });
113
+
114
+ const params: MessageCreateParamsStreaming = {
115
+ model: model.id,
116
+ messages: convertPiMessagesToAnthropic(context.messages, isOAuth),
117
+ max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
118
+ stream: true,
119
+ };
120
+
121
+ const system = buildAnthropicSystemPrompt(context.systemPrompt, isOAuth);
122
+ if (system) params.system = system as never;
123
+ if (context.tools?.length)
124
+ params.tools = convertPiToolsToAnthropic(context.tools, isOAuth);
125
+
126
+ if (options?.reasoning && model.reasoning) {
127
+ const defaultBudgets: Record<string, number> = {
128
+ minimal: 1024,
129
+ low: 4096,
130
+ medium: 10240,
131
+ high: 20480,
132
+ xhigh: 32000,
133
+ };
134
+ const customBudget =
135
+ options.thinkingBudgets?.[
136
+ options.reasoning as keyof typeof options.thinkingBudgets
137
+ ];
138
+ params.thinking = {
139
+ type: "enabled",
140
+ budget_tokens:
141
+ customBudget ?? defaultBudgets[options.reasoning] ?? 10240,
142
+ };
143
+ }
144
+
145
+ const anthropicStream = client.messages.stream(params, {
146
+ signal: options?.signal,
147
+ });
148
+ stream.push({ type: "start", partial: output });
149
+
150
+ const blocks = output.content as IndexedBlock[];
151
+
152
+ for await (const event of anthropicStream) {
153
+ if (event.type === "message_start") {
154
+ output.usage.input = event.message.usage.input_tokens || 0;
155
+ output.usage.output = event.message.usage.output_tokens || 0;
156
+ output.usage.cacheRead =
157
+ (event.message.usage as { cache_read_input_tokens?: number })
158
+ .cache_read_input_tokens || 0;
159
+ output.usage.cacheWrite =
160
+ (event.message.usage as { cache_creation_input_tokens?: number })
161
+ .cache_creation_input_tokens || 0;
162
+ output.usage.totalTokens =
163
+ output.usage.input +
164
+ output.usage.output +
165
+ output.usage.cacheRead +
166
+ output.usage.cacheWrite;
167
+ calculateCost(model, output.usage);
168
+ continue;
169
+ }
170
+
171
+ if (event.type === "content_block_start") {
172
+ if (event.content_block.type === "text") {
173
+ output.content.push({
174
+ type: "text",
175
+ text: "",
176
+ index: event.index,
177
+ } as IndexedBlock);
178
+ stream.push({
179
+ type: "text_start",
180
+ contentIndex: output.content.length - 1,
181
+ partial: output,
182
+ });
183
+ } else if (event.content_block.type === "thinking") {
184
+ output.content.push({
185
+ type: "thinking",
186
+ thinking: "",
187
+ thinkingSignature: "",
188
+ index: event.index,
189
+ } as IndexedBlock);
190
+ stream.push({
191
+ type: "thinking_start",
192
+ contentIndex: output.content.length - 1,
193
+ partial: output,
194
+ });
195
+ } else if (event.content_block.type === "tool_use") {
196
+ output.content.push({
197
+ type: "toolCall",
198
+ id: event.content_block.id,
199
+ name: isOAuth
200
+ ? fromClaudeCodeToolName(
201
+ event.content_block.name,
202
+ context.tools,
203
+ )
204
+ : event.content_block.name,
205
+ arguments: {},
206
+ partialJson: "",
207
+ index: event.index,
208
+ } as IndexedBlock);
209
+ stream.push({
210
+ type: "toolcall_start",
211
+ contentIndex: output.content.length - 1,
212
+ partial: output,
213
+ });
214
+ }
215
+ continue;
216
+ }
217
+
218
+ if (event.type === "content_block_delta") {
219
+ const contentIndex = blocks.findIndex(
220
+ (block) => block.index === event.index,
221
+ );
222
+ const block = blocks[contentIndex];
223
+ if (!block) continue;
224
+
225
+ if (event.delta.type === "text_delta" && block.type === "text") {
226
+ block.text += event.delta.text;
227
+ stream.push({
228
+ type: "text_delta",
229
+ contentIndex,
230
+ delta: event.delta.text,
231
+ partial: output,
232
+ });
233
+ } else if (
234
+ event.delta.type === "thinking_delta" &&
235
+ block.type === "thinking"
236
+ ) {
237
+ block.thinking += event.delta.thinking;
238
+ stream.push({
239
+ type: "thinking_delta",
240
+ contentIndex,
241
+ delta: event.delta.thinking,
242
+ partial: output,
243
+ });
244
+ } else if (
245
+ event.delta.type === "signature_delta" &&
246
+ block.type === "thinking"
247
+ ) {
248
+ block.thinkingSignature =
249
+ (block.thinkingSignature || "") + event.delta.signature;
250
+ } else if (
251
+ event.delta.type === "input_json_delta" &&
252
+ block.type === "toolCall"
253
+ ) {
254
+ block.partialJson += event.delta.partial_json;
255
+ try {
256
+ block.arguments = JSON.parse(block.partialJson) as Record<
257
+ string,
258
+ unknown
259
+ >;
260
+ } catch {}
261
+ stream.push({
262
+ type: "toolcall_delta",
263
+ contentIndex,
264
+ delta: event.delta.partial_json,
265
+ partial: output,
266
+ });
267
+ }
268
+ continue;
269
+ }
270
+
271
+ if (event.type === "content_block_stop") {
272
+ const contentIndex = blocks.findIndex(
273
+ (block) => block.index === event.index,
274
+ );
275
+ const block = blocks[contentIndex];
276
+ if (!block) continue;
277
+
278
+ delete (block as { index?: number }).index;
279
+ if (block.type === "text") {
280
+ stream.push({
281
+ type: "text_end",
282
+ contentIndex,
283
+ content: block.text,
284
+ partial: output,
285
+ });
286
+ } else if (block.type === "thinking") {
287
+ stream.push({
288
+ type: "thinking_end",
289
+ contentIndex,
290
+ content: block.thinking,
291
+ partial: output,
292
+ });
293
+ } else if (block.type === "toolCall") {
294
+ try {
295
+ block.arguments = JSON.parse(block.partialJson) as Record<
296
+ string,
297
+ unknown
298
+ >;
299
+ } catch {}
300
+ delete (block as { partialJson?: string }).partialJson;
301
+ stream.push({
302
+ type: "toolcall_end",
303
+ contentIndex,
304
+ toolCall: block,
305
+ partial: output,
306
+ });
307
+ }
308
+ continue;
309
+ }
310
+
311
+ if (event.type === "message_delta") {
312
+ output.stopReason = mapStopReason(event.delta.stop_reason);
313
+ output.usage.input =
314
+ (event.usage as { input_tokens?: number }).input_tokens ||
315
+ output.usage.input;
316
+ output.usage.output =
317
+ (event.usage as { output_tokens?: number }).output_tokens ||
318
+ output.usage.output;
319
+ output.usage.cacheRead =
320
+ (event.usage as { cache_read_input_tokens?: number })
321
+ .cache_read_input_tokens || 0;
322
+ output.usage.cacheWrite =
323
+ (event.usage as { cache_creation_input_tokens?: number })
324
+ .cache_creation_input_tokens || 0;
325
+ output.usage.totalTokens =
326
+ output.usage.input +
327
+ output.usage.output +
328
+ output.usage.cacheRead +
329
+ output.usage.cacheWrite;
330
+ calculateCost(model, output.usage);
331
+ }
332
+ }
333
+
334
+ if (options?.signal?.aborted) throw new Error("Request aborted");
335
+ stream.push({
336
+ type: "done",
337
+ reason: output.stopReason as "stop" | "length" | "toolUse",
338
+ message: output,
339
+ });
340
+ stream.end();
341
+ } catch (error) {
342
+ for (const block of output.content as Array<{
343
+ index?: number;
344
+ partialJson?: string;
345
+ }>) {
346
+ delete block.index;
347
+ delete block.partialJson;
348
+ }
349
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
350
+ output.errorMessage =
351
+ error instanceof Error ? error.message : String(error);
352
+ stream.push({ type: "error", reason: output.stopReason, error: output });
353
+ stream.end();
354
+ }
355
+ })();
356
+
357
+ return stream;
358
+ }