pi-anthropic-oauth 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -1,13 +1,20 @@
1
- # Pi Anthropic OAuth
1
+ # pi-anthropic-oauth
2
2
 
3
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
4
 
5
- Claude Pro/Max OAuth extension for Pi.
5
+ Use Claude Pro/Max in Pi with browser OAuth.
6
6
 
7
- > [!WARNING]
8
- > Use this at your own risk. This may go against Anthropic's terms.
7
+ ## Features
8
+
9
+ - Claude Pro/Max login from `/login anthropic`
10
+ - Automatic token refresh
11
+ - Claude Code-compatible OAuth headers and prompt shaping
12
+ - No API key or extra usage needed.
13
+ - Uses Pi's Anthropic model registry
14
+ - Adds Claude Opus 4.7 by default
15
+ - Auto-creates `~/.Claude Code` → `~/.pi` symlink when missing
9
16
 
10
- ## Install
17
+ ## Quick start
11
18
 
12
19
  ```bash
13
20
  pi install npm:pi-anthropic-oauth
@@ -25,11 +32,42 @@ Choose:
25
32
  Claude Pro/Max
26
33
  ```
27
34
 
28
- ## Notes
35
+ > [!WARNING]
36
+ > Use at your own risk. This may go against Anthropic's terms.
37
+
38
+ > [!NOTE]
39
+ > Anthropic auth changes are closely monitored for quick compatibility updates.
40
+
41
+ ## Extra models
42
+
43
+ To add another Anthropic model, create `~/.pi/agent/models.json`:
44
+
45
+ ```json
46
+ {
47
+ "providers": {
48
+ "anthropic": {
49
+ "baseUrl": "https://api.anthropic.com",
50
+ "apiKey": "REQUIRED_BY_PI_FOR_CUSTOM_MODELS",
51
+ "api": "anthropic-messages",
52
+ "models": [
53
+ {
54
+ "id": "your-model-id",
55
+ "name": "Your Model Name"
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ > [!NOTE]
64
+ > Pi requires `baseUrl`, `apiKey`, and `api` when defining custom models in `models.json`. With this extension, requests normally authenticate through Claude Pro/Max OAuth after `/login anthropic`, so `apiKey` is present to satisfy Pi's config requirements and does not need to be a valid Anthropic API key.
65
+
66
+ ## Troubleshooting
29
67
 
30
- - sends Claude Code-like OAuth headers
31
- - rewrites Pi system identity where needed
32
- - auto-creates `~/.Claude Code` → `~/.pi` symlink
68
+ - Re-run `/login anthropic` if auth looks stale
69
+ - If local callback login does not complete, paste the final callback URL or `code#state` when prompted
70
+ - If something breaks, please open an issue with your Pi version, extension version, and error output
33
71
 
34
72
  ## License
35
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anthropic-oauth",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Claude Pro/Max OAuth extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/auth.ts CHANGED
@@ -152,7 +152,6 @@ export async function refreshAnthropicToken(
152
152
  grant_type: "refresh_token",
153
153
  client_id: CLIENT_ID,
154
154
  refresh_token: credentials.refresh,
155
- scope: SCOPES,
156
155
  }),
157
156
  },
158
157
  "Token refresh",
package/src/convert.ts CHANGED
@@ -73,10 +73,33 @@ export function convertPiMessagesToAnthropic(
73
73
  return candidate;
74
74
  };
75
75
 
76
+ // Anthropic requires every `tool_use` block to be IMMEDIATELY followed by a
77
+ // user message containing a matching `tool_result` for the same id. Aborted
78
+ // / errored assistant turns or partial histories can leave orphan tool_uses
79
+ // behind, which causes the API to reject the next request with:
80
+ // `tool_use` ids were found without `tool_result` blocks immediately after
81
+ // We track outstanding tool_use ids from the last assistant message and
82
+ // synthesize "No result provided" results for any that aren't satisfied by
83
+ // the time we emit the next user message (or finish the history).
84
+ let pendingToolUseIds: string[] = [];
85
+
86
+ const flushPendingToolResults = () => {
87
+ if (pendingToolUseIds.length === 0) return;
88
+ const syntheticBlocks: ContentBlockParam[] = pendingToolUseIds.map((id) => ({
89
+ type: "tool_result",
90
+ tool_use_id: id,
91
+ content: "No result provided",
92
+ is_error: true,
93
+ }));
94
+ params.push({ role: "user", content: syntheticBlocks });
95
+ pendingToolUseIds = [];
96
+ };
97
+
76
98
  for (let i = 0; i < messages.length; i++) {
77
99
  const message = messages[i];
78
100
 
79
101
  if (message.role === "user") {
102
+ flushPendingToolResults();
80
103
  if (typeof message.content === "string") {
81
104
  if (message.content.trim()) params.push({ role: "user", content: sanitizeSurrogates(message.content) });
82
105
  } else {
@@ -94,45 +117,85 @@ export function convertPiMessagesToAnthropic(
94
117
  }
95
118
 
96
119
  if (message.role === "assistant") {
120
+ // Skip aborted / errored assistant turns entirely. Their content may
121
+ // include partial tool_use blocks that will never get a matching
122
+ // tool_result, which would poison every subsequent request.
123
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
124
+ continue;
125
+ }
126
+
127
+ // Defensive: if the previous assistant turn had unresolved tool_uses and
128
+ // the next message is another assistant (shouldn't normally happen),
129
+ // patch them up before emitting the new assistant turn.
130
+ flushPendingToolResults();
131
+
97
132
  const blocks: ContentBlockParam[] = [];
133
+ const emittedToolUseIds: string[] = [];
98
134
  for (const block of message.content) {
99
135
  if (block.type === "text" && block.text.trim()) {
100
136
  blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
101
137
  } else if (block.type === "toolCall") {
138
+ const anthropicId = getAnthropicToolId(block.id);
102
139
  blocks.push({
103
140
  type: "tool_use",
104
- id: getAnthropicToolId(block.id),
141
+ id: anthropicId,
105
142
  name: isOAuth ? toClaudeCodeToolName(block.name) : block.name,
106
- input: block.arguments,
143
+ input: block.arguments ?? {},
107
144
  });
145
+ emittedToolUseIds.push(anthropicId);
108
146
  }
109
147
  }
110
- if (blocks.length > 0) params.push({ role: "assistant", content: blocks });
148
+ if (blocks.length > 0) {
149
+ params.push({ role: "assistant", content: blocks });
150
+ pendingToolUseIds = emittedToolUseIds;
151
+ }
111
152
  continue;
112
153
  }
113
154
 
114
155
  if (message.role === "toolResult") {
115
- const toolResults = [
116
- {
117
- type: "tool_result" as const,
118
- tool_use_id: getAnthropicToolId(message.toolCallId),
119
- content: convertToolResultContentToAnthropic(message.content),
120
- is_error: message.isError,
121
- },
122
- ];
156
+ const toolResults: ContentBlockParam[] = [];
157
+ const satisfiedIds = new Set<string>();
158
+
159
+ const firstId = getAnthropicToolId(message.toolCallId);
160
+ toolResults.push({
161
+ type: "tool_result",
162
+ tool_use_id: firstId,
163
+ content: convertToolResultContentToAnthropic(message.content),
164
+ is_error: message.isError,
165
+ });
166
+ satisfiedIds.add(firstId);
123
167
 
124
168
  let j = i + 1;
125
169
  while (j < messages.length && messages[j]?.role === "toolResult") {
126
170
  const nextMessage = messages[j] as ToolResultMessage;
171
+ const nextId = getAnthropicToolId(nextMessage.toolCallId);
127
172
  toolResults.push({
128
- type: "tool_result" as const,
129
- tool_use_id: getAnthropicToolId(nextMessage.toolCallId),
173
+ type: "tool_result",
174
+ tool_use_id: nextId,
130
175
  content: convertToolResultContentToAnthropic(nextMessage.content),
131
176
  is_error: nextMessage.isError,
132
177
  });
178
+ satisfiedIds.add(nextId);
133
179
  j++;
134
180
  }
135
181
  i = j - 1;
182
+
183
+ // Synthesize "No result provided" for any tool_use from the last
184
+ // assistant message that wasn't covered by the toolResult run. They
185
+ // must live in the SAME user message as the real tool_results so that
186
+ // every tool_use is immediately followed by matching results.
187
+ for (const id of pendingToolUseIds) {
188
+ if (!satisfiedIds.has(id)) {
189
+ toolResults.push({
190
+ type: "tool_result",
191
+ tool_use_id: id,
192
+ content: "No result provided",
193
+ is_error: true,
194
+ });
195
+ }
196
+ }
197
+ pendingToolUseIds = [];
198
+
136
199
  params.push({ role: "user", content: toolResults });
137
200
  }
138
201
  }
package/src/index.ts CHANGED
@@ -1,58 +1,27 @@
1
1
  import { existsSync, symlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import type { ExtensionAPI, ProviderConfig } from "@mariozechner/pi-coding-agent";
4
+ import {
5
+ AuthStorage,
6
+ ModelRegistry,
7
+ type ExtensionAPI,
8
+ type ProviderConfig,
9
+ } from "@mariozechner/pi-coding-agent";
5
10
  import type { OAuthCredentials } from "@mariozechner/pi-ai";
6
11
  import { loginAnthropic, refreshAnthropicToken } from "./auth.js";
7
12
  import { streamAnthropicOAuth } from "./stream.js";
8
13
 
9
- const MODELS = [
10
- {
11
- id: "claude-opus-4-7",
12
- name: "Claude Opus 4.7",
13
- reasoning: true,
14
- input: ["text", "image"] as ("text" | "image")[],
15
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
16
- contextWindow: 1000000,
17
- maxTokens: 128000,
18
- },
19
- {
20
- id: "claude-opus-4-6",
21
- name: "Claude Opus 4.6",
22
- reasoning: true,
23
- input: ["text", "image"] as ("text" | "image")[],
24
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
25
- contextWindow: 1000000,
26
- maxTokens: 128000,
27
- },
28
- {
29
- id: "claude-sonnet-4-6",
30
- name: "Claude Sonnet 4.6",
31
- reasoning: true,
32
- input: ["text", "image"] as ("text" | "image")[],
33
- cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
34
- contextWindow: 1000000,
35
- maxTokens: 64000,
36
- },
37
- {
38
- id: "claude-haiku-4-5",
39
- name: "Claude Haiku 4.5",
40
- reasoning: true,
41
- input: ["text", "image"] as ("text" | "image")[],
42
- cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
43
- contextWindow: 200000,
44
- maxTokens: 64000,
45
- },
46
- {
47
- id: "claude-opus-4-5-20251101",
48
- name: "Claude Opus 4.5",
49
- reasoning: true,
50
- input: ["text", "image"] as ("text" | "image")[],
51
- cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
52
- contextWindow: 200000,
53
- maxTokens: 64000,
54
- }
55
- ];
14
+ const DEFAULT_OPUS_4_7: NonNullable<ProviderConfig["models"]>[number] = {
15
+ id: "claude-opus-4-7",
16
+ name: "Claude Opus 4.7",
17
+ api: "anthropic-messages",
18
+ reasoning: true,
19
+ input: ["text", "image"],
20
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
21
+ contextWindow: 1000000,
22
+ maxTokens: 128000,
23
+ compat: undefined,
24
+ };
56
25
 
57
26
  function ensureClaudeCodeSymlink() {
58
27
  const target = join(homedir(), ".pi");
@@ -64,13 +33,38 @@ function ensureClaudeCodeSymlink() {
64
33
  }
65
34
  }
66
35
 
36
+ function getAnthropicModels(): NonNullable<ProviderConfig["models"]> {
37
+ const modelRegistry = ModelRegistry.create(AuthStorage.inMemory());
38
+ const models: NonNullable<ProviderConfig["models"]> = modelRegistry
39
+ .getAll()
40
+ .filter((model) => model.provider === "anthropic")
41
+ .map((model) => ({
42
+ id: model.id,
43
+ name: model.name,
44
+ api: model.api ?? "anthropic-messages",
45
+ reasoning: model.reasoning,
46
+ input: model.input,
47
+ cost: model.cost,
48
+ contextWindow: model.contextWindow,
49
+ maxTokens: model.maxTokens,
50
+ compat: model.compat,
51
+ }));
52
+
53
+ if (!models.some((model) => model.id === DEFAULT_OPUS_4_7.id)) {
54
+ models.push(DEFAULT_OPUS_4_7);
55
+ }
56
+
57
+ return models;
58
+ }
59
+
67
60
  export default function (pi: ExtensionAPI) {
68
61
  ensureClaudeCodeSymlink();
62
+ const models = getAnthropicModels();
63
+
69
64
  pi.registerProvider("anthropic", {
70
65
  baseUrl: "https://api.anthropic.com",
71
- apiKey: "ANTHROPIC_MAX_API_KEY",
72
- api: "anthropic-max-api",
73
- models: [...MODELS],
66
+ api: "anthropic-messages",
67
+ models,
74
68
  oauth: {
75
69
  name: "Claude Pro/Max",
76
70
  usesCallbackServer: true,
package/src/stream.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type SimpleStreamOptions,
12
12
  type StopReason,
13
13
  } from "@mariozechner/pi-ai";
14
- import { USER_AGENT } from "./auth.js";
14
+ import { isClaudeOAuthAccessToken, USER_AGENT } from "./auth.js";
15
15
  import {
16
16
  convertPiMessagesToAnthropic,
17
17
  convertPiToolsToAnthropic,
@@ -105,8 +105,12 @@ export function streamAnthropicOAuth(
105
105
  };
106
106
 
107
107
  try {
108
- const apiKey = options?.apiKey ?? "";
109
- const isOAuth = true;
108
+ const apiKey = options?.apiKey;
109
+ if (!apiKey) {
110
+ throw new Error("No Anthropic auth available. Run /login anthropic.");
111
+ }
112
+
113
+ const isOAuth = isClaudeOAuthAccessToken(apiKey);
110
114
  const defaultHeaders = makeDefaultHeaders(isOAuth, options);
111
115
 
112
116
  if (isOAuth) defaultHeaders.authorization = `Bearer ${apiKey}`;