op-anthropic-auth-v2 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 +22 -0
- package/README.md +60 -0
- package/index.js +448 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 parzival1l (op-anthropic-auth-v2 port)
|
|
4
|
+
Copyright (c) leohenon (original op-anthropic-auth, https://github.com/leohenon/op-anthropic-auth)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# op-anthropic-auth-v2
|
|
2
|
+
|
|
3
|
+
OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max.
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> This is an unofficial compatibility plugin. Anthropic may restrict third-party use
|
|
7
|
+
> of Claude subscription OAuth credentials, and using it could put your account at
|
|
8
|
+
> risk. Review Anthropic's current terms and use it at your own discretion.
|
|
9
|
+
|
|
10
|
+
Port of [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) (MIT, by
|
|
11
|
+
leohenon) to the [OpenCode 2](https://opencode.ai/v2/docs) plugin API. The original
|
|
12
|
+
plugin targets OpenCode 1 and cannot load in v2 — this package is the v2 equivalent.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
OpenCode 2 beta sends Anthropic OAuth requests as-is, and Anthropic rejects them with
|
|
17
|
+
an opaque `429` (`{"message":"Error"}`). This plugin rewrites every request to
|
|
18
|
+
`api.anthropic.com` so it passes OAuth gating:
|
|
19
|
+
|
|
20
|
+
- sets the `claude-cli` user-agent and required `anthropic-beta` headers
|
|
21
|
+
- adds the `x-anthropic-billing-header` system block
|
|
22
|
+
- prepends the Claude Code identity to the system prompt and relocates remaining
|
|
23
|
+
system text into the first user message
|
|
24
|
+
- prefixes tool names with `mcp_` and rewrites the response stream back
|
|
25
|
+
- refreshes the OAuth token on expiry and persists it to the shared
|
|
26
|
+
`~/.local/share/opencode/auth.json`
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
opencode2 plugin add op-anthropic-auth-v2
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
or add it to `~/.config/opencode/opencode.jsonc`:
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
{
|
|
38
|
+
"plugins": ["op-anthropic-auth-v2"]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires existing Anthropic OAuth credentials (`opencode2 auth login`, or credentials
|
|
43
|
+
already stored by OpenCode 1 in `~/.local/share/opencode/auth.json`). Users with a
|
|
44
|
+
plain API key are unaffected — the plugin leaves non-OAuth setups untouched.
|
|
45
|
+
|
|
46
|
+
For OpenCode 1, keep using [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth).
|
|
47
|
+
Both can coexist: v1 ignores this package's format, v2 ignores the v1 package.
|
|
48
|
+
|
|
49
|
+
## Compatibility
|
|
50
|
+
|
|
51
|
+
Built against `opencode2` `0.0.0-beta-18269`. The v2 plugin API is beta and may
|
|
52
|
+
change; pin accordingly.
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm test
|
|
58
|
+
npm run check
|
|
59
|
+
npm run pack:dry-run
|
|
60
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Anthropic OAuth (Claude Pro/Max) support for OpenCode 2.
|
|
2
|
+
// Port of op-anthropic-auth@0.1.4 to the v2 plugin API.
|
|
3
|
+
// v1 loads op-anthropic-auth itself; this file targets opencode2 only.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
10
|
+
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
11
|
+
const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
|
|
12
|
+
const TOOL_PREFIX = "mcp_";
|
|
13
|
+
const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
|
|
14
|
+
const CLAUDE_CODE_IDENTITY =
|
|
15
|
+
"You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
16
|
+
const PARAGRAPH_REMOVAL_ANCHORS = [
|
|
17
|
+
"github.com/anomalyco/opencode",
|
|
18
|
+
"opencode.ai/docs",
|
|
19
|
+
];
|
|
20
|
+
const TEXT_REPLACEMENTS = [
|
|
21
|
+
{ match: "if OpenCode honestly", replacement: "if the assistant honestly" },
|
|
22
|
+
];
|
|
23
|
+
const CLAUDE_CODE_VERSION = "2.1.87";
|
|
24
|
+
const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
|
|
25
|
+
const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
26
|
+
const CCH_SALT = "59cf53e54c78";
|
|
27
|
+
const CCH_POSITIONS = [4, 7, 20];
|
|
28
|
+
const REQUEST_USER_AGENT = "claude-cli/2.1.87 (external, cli)";
|
|
29
|
+
const TOKEN_USER_AGENT = "axios/1.13.6";
|
|
30
|
+
|
|
31
|
+
const AUTH_FILE =
|
|
32
|
+
process.env.XDG_DATA_HOME != null
|
|
33
|
+
? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
|
|
34
|
+
: join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
35
|
+
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isOAuthAuth(value) {
|
|
41
|
+
return (
|
|
42
|
+
isRecord(value) &&
|
|
43
|
+
value.type === "oauth" &&
|
|
44
|
+
typeof value.refresh === "string" &&
|
|
45
|
+
typeof value.expires === "number"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- auth store ----
|
|
50
|
+
|
|
51
|
+
function readAuthFile() {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(AUTH_FILE, "utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeAuthEntry(entry) {
|
|
60
|
+
const all = readAuthFile() ?? {};
|
|
61
|
+
all.anthropic = entry;
|
|
62
|
+
const tmp = `${AUTH_FILE}.tmp-${process.pid}`;
|
|
63
|
+
writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
64
|
+
chmodSync(tmp, 0o600);
|
|
65
|
+
renameSync(tmp, AUTH_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let refreshPromise = null;
|
|
69
|
+
|
|
70
|
+
async function refreshAccessToken(auth) {
|
|
71
|
+
let refreshToken = auth.refresh;
|
|
72
|
+
const maxRetries = 2;
|
|
73
|
+
const baseDelayMs = 500;
|
|
74
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
75
|
+
if (attempt > 0) {
|
|
76
|
+
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
|
|
77
|
+
}
|
|
78
|
+
const response = await fetch(TOKEN_URL, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
Accept: "application/json, text/plain, */*",
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"User-Agent": TOKEN_USER_AGENT,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
grant_type: "refresh_token",
|
|
87
|
+
refresh_token: refreshToken,
|
|
88
|
+
client_id: CLIENT_ID,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
93
|
+
await response.body?.cancel();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// On 401, another process may have already rotated the refresh token.
|
|
97
|
+
if (response.status === 401 && attempt < maxRetries) {
|
|
98
|
+
const updated = readAuthFile()?.anthropic;
|
|
99
|
+
if (isOAuthAuth(updated) && updated.refresh !== refreshToken) {
|
|
100
|
+
await response.body?.cancel();
|
|
101
|
+
if (updated.access && updated.expires > Date.now()) {
|
|
102
|
+
return updated.access;
|
|
103
|
+
}
|
|
104
|
+
refreshToken = updated.refresh;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const body = await response.text().catch(() => "");
|
|
109
|
+
throw new Error(`Token refresh failed: ${response.status} — ${body}`);
|
|
110
|
+
}
|
|
111
|
+
const json = await response.json();
|
|
112
|
+
const entry = {
|
|
113
|
+
type: "oauth",
|
|
114
|
+
refresh: json.refresh_token,
|
|
115
|
+
access: json.access_token,
|
|
116
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
117
|
+
};
|
|
118
|
+
writeAuthEntry(entry);
|
|
119
|
+
return entry.access;
|
|
120
|
+
}
|
|
121
|
+
throw new Error("Token refresh failed: retries exhausted");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function currentAccessToken() {
|
|
125
|
+
const auth = readAuthFile()?.anthropic;
|
|
126
|
+
if (!isOAuthAuth(auth)) return null;
|
|
127
|
+
if (auth.access && auth.expires > Date.now() + 30_000) return auth.access;
|
|
128
|
+
if (!refreshPromise) {
|
|
129
|
+
refreshPromise = refreshAccessToken(auth).finally(() => {
|
|
130
|
+
refreshPromise = null;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return refreshPromise;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- request rewriting ----
|
|
137
|
+
|
|
138
|
+
function mergeBetaHeaders(headers) {
|
|
139
|
+
const incoming = (headers.get("anthropic-beta") || "")
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((beta) => beta.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
return [...new Set([...REQUIRED_BETAS, ...incoming])].join(",");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sanitizeSystemText(text) {
|
|
147
|
+
const paragraphs = text.split(/\n\n+/);
|
|
148
|
+
const filtered = paragraphs.filter((paragraph) => {
|
|
149
|
+
if (paragraph.includes(OPENCODE_IDENTITY_PREFIX)) return false;
|
|
150
|
+
for (const anchor of PARAGRAPH_REMOVAL_ANCHORS) {
|
|
151
|
+
if (paragraph.includes(anchor)) return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
});
|
|
155
|
+
let result = filtered.join("\n\n");
|
|
156
|
+
for (const rule of TEXT_REPLACEMENTS) {
|
|
157
|
+
result = result.replace(rule.match, rule.replacement);
|
|
158
|
+
}
|
|
159
|
+
return result.trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function prependClaudeCodeIdentity(system) {
|
|
163
|
+
const identityBlock = { type: "text", text: CLAUDE_CODE_IDENTITY };
|
|
164
|
+
if (system == null) return [identityBlock];
|
|
165
|
+
if (typeof system === "string") {
|
|
166
|
+
const sanitized = sanitizeSystemText(system);
|
|
167
|
+
if (sanitized === CLAUDE_CODE_IDENTITY) return [identityBlock];
|
|
168
|
+
return [identityBlock, { type: "text", text: sanitized }];
|
|
169
|
+
}
|
|
170
|
+
if (isRecord(system)) {
|
|
171
|
+
const type = typeof system.type === "string" ? system.type : "text";
|
|
172
|
+
const text = typeof system.text === "string" ? system.text : "";
|
|
173
|
+
return [identityBlock, { ...system, type, text: sanitizeSystemText(text) }];
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(system)) return [identityBlock];
|
|
176
|
+
const sanitized = system.map((item) => {
|
|
177
|
+
if (typeof item === "string") {
|
|
178
|
+
return { type: "text", text: sanitizeSystemText(item) };
|
|
179
|
+
}
|
|
180
|
+
if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
|
|
181
|
+
return { ...item, type: "text", text: sanitizeSystemText(item.text) };
|
|
182
|
+
}
|
|
183
|
+
return { type: "text", text: String(item) };
|
|
184
|
+
});
|
|
185
|
+
if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) return sanitized;
|
|
186
|
+
return [identityBlock, ...sanitized];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function extractFirstUserMessageText(messages) {
|
|
190
|
+
const userMsg = messages.find((message) => message.role === "user");
|
|
191
|
+
if (!userMsg) return "";
|
|
192
|
+
const { content } = userMsg;
|
|
193
|
+
if (typeof content === "string") return content;
|
|
194
|
+
if (Array.isArray(content)) {
|
|
195
|
+
const textBlock = content.find((block) => block.type === "text");
|
|
196
|
+
if (textBlock?.text) return textBlock.text;
|
|
197
|
+
}
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function computeCCH(messageText) {
|
|
202
|
+
return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function computeVersionSuffix(messageText) {
|
|
206
|
+
const chars = CCH_POSITIONS.map((index) => messageText[index] || "0").join("");
|
|
207
|
+
return createHash("sha256")
|
|
208
|
+
.update(`${CCH_SALT}${chars}${CLAUDE_CODE_VERSION}`)
|
|
209
|
+
.digest("hex")
|
|
210
|
+
.slice(0, 3);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildBillingHeaderValue(messages) {
|
|
214
|
+
const text = extractFirstUserMessageText(messages);
|
|
215
|
+
const suffix = computeVersionSuffix(text);
|
|
216
|
+
const cch = computeCCH(text);
|
|
217
|
+
return (
|
|
218
|
+
`${BILLING_HEADER_PREFIX} ` +
|
|
219
|
+
`cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` +
|
|
220
|
+
`cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` +
|
|
221
|
+
`cch=${cch};`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function relocateSystemEntriesToFirstUserMessage(system, messages) {
|
|
226
|
+
if (!Array.isArray(system) || !Array.isArray(messages)) return system;
|
|
227
|
+
const firstUserMessage = messages.find((message) => message.role === "user");
|
|
228
|
+
if (!firstUserMessage) return system;
|
|
229
|
+
const kept = [];
|
|
230
|
+
const moved = [];
|
|
231
|
+
for (const entry of system) {
|
|
232
|
+
if (isRecord(entry) && typeof entry.text === "string") {
|
|
233
|
+
const text = entry.text.trim();
|
|
234
|
+
if (!text) continue;
|
|
235
|
+
if (text.startsWith(BILLING_HEADER_PREFIX) || text === CLAUDE_CODE_IDENTITY) {
|
|
236
|
+
kept.push({ type: "text", text });
|
|
237
|
+
} else {
|
|
238
|
+
moved.push(text);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (typeof entry === "string") {
|
|
243
|
+
const text = entry.trim();
|
|
244
|
+
if (text) moved.push(text);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!moved.length) return kept;
|
|
248
|
+
const relocatedText = moved.join("\n\n");
|
|
249
|
+
if (typeof firstUserMessage.content === "string") {
|
|
250
|
+
firstUserMessage.content = firstUserMessage.content
|
|
251
|
+
? `${relocatedText}\n\n${firstUserMessage.content}`
|
|
252
|
+
: relocatedText;
|
|
253
|
+
} else if (Array.isArray(firstUserMessage.content)) {
|
|
254
|
+
firstUserMessage.content = [
|
|
255
|
+
{ type: "text", text: relocatedText },
|
|
256
|
+
...firstUserMessage.content,
|
|
257
|
+
];
|
|
258
|
+
} else {
|
|
259
|
+
firstUserMessage.content = relocatedText;
|
|
260
|
+
}
|
|
261
|
+
return kept;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function prefixName(name) {
|
|
265
|
+
return `${TOOL_PREFIX}${name}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function unprefixName(name) {
|
|
269
|
+
return name;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function prefixToolNames(parsed) {
|
|
273
|
+
if (parsed.tools && Array.isArray(parsed.tools)) {
|
|
274
|
+
parsed.tools = parsed.tools.map((tool) => ({
|
|
275
|
+
...tool,
|
|
276
|
+
name: tool.name ? prefixName(tool.name) : tool.name,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
if (parsed.messages && Array.isArray(parsed.messages)) {
|
|
280
|
+
parsed.messages = parsed.messages.map((msg) => {
|
|
281
|
+
if (msg.content && Array.isArray(msg.content)) {
|
|
282
|
+
msg.content = msg.content.map((block) => {
|
|
283
|
+
if (block.type === "tool_use" && block.name) {
|
|
284
|
+
return { ...block, name: prefixName(block.name) };
|
|
285
|
+
}
|
|
286
|
+
return block;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return msg;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return JSON.stringify(parsed);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function rewriteRequestBody(body) {
|
|
296
|
+
if (!body || typeof body !== "string") return body;
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(body);
|
|
299
|
+
const billingHeader =
|
|
300
|
+
Array.isArray(parsed.messages) &&
|
|
301
|
+
parsed.messages.some((message) => message.role === "user")
|
|
302
|
+
? buildBillingHeaderValue(parsed.messages)
|
|
303
|
+
: null;
|
|
304
|
+
parsed.system = prependClaudeCodeIdentity(parsed.system);
|
|
305
|
+
if (billingHeader && Array.isArray(parsed.system)) {
|
|
306
|
+
parsed.system.unshift({ type: "text", text: billingHeader });
|
|
307
|
+
}
|
|
308
|
+
parsed.system = relocateSystemEntriesToFirstUserMessage(
|
|
309
|
+
parsed.system,
|
|
310
|
+
parsed.messages,
|
|
311
|
+
);
|
|
312
|
+
return prefixToolNames(parsed);
|
|
313
|
+
} catch {
|
|
314
|
+
return body;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function rewriteResponse(response) {
|
|
319
|
+
if (!response.body) return response;
|
|
320
|
+
const reader = response.body.getReader();
|
|
321
|
+
const decoder = new TextDecoder();
|
|
322
|
+
const encoder = new TextEncoder();
|
|
323
|
+
const isEventStream = response.headers
|
|
324
|
+
.get("content-type")
|
|
325
|
+
?.toLowerCase()
|
|
326
|
+
.includes("text/event-stream");
|
|
327
|
+
let buffered = "";
|
|
328
|
+
let finished = false;
|
|
329
|
+
|
|
330
|
+
const rewriteText = (text) =>
|
|
331
|
+
text.replace(
|
|
332
|
+
/"name"\s*:\s*"mcp_([^"]+)"/g,
|
|
333
|
+
(_match, name) => `"name": "${unprefixName(name)}"`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const stream = new ReadableStream({
|
|
337
|
+
async pull(controller) {
|
|
338
|
+
while (true) {
|
|
339
|
+
if (isEventStream) {
|
|
340
|
+
const boundary = buffered.match(/\r?\n\r?\n/);
|
|
341
|
+
if (boundary?.index != null) {
|
|
342
|
+
const end = boundary.index + boundary[0].length;
|
|
343
|
+
const event = buffered.slice(0, end);
|
|
344
|
+
buffered = buffered.slice(end);
|
|
345
|
+
controller.enqueue(encoder.encode(rewriteText(event)));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (finished) {
|
|
351
|
+
if (buffered) {
|
|
352
|
+
controller.enqueue(encoder.encode(rewriteText(buffered)));
|
|
353
|
+
buffered = "";
|
|
354
|
+
} else {
|
|
355
|
+
controller.close();
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const { done, value } = await reader.read();
|
|
361
|
+
if (done) {
|
|
362
|
+
buffered += decoder.decode();
|
|
363
|
+
finished = true;
|
|
364
|
+
} else {
|
|
365
|
+
buffered += decoder.decode(value, { stream: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
async cancel(reason) {
|
|
370
|
+
await reader.cancel(reason);
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
const headers = new Headers(response.headers);
|
|
374
|
+
headers.delete("content-encoding");
|
|
375
|
+
headers.delete("content-length");
|
|
376
|
+
return new Response(stream, {
|
|
377
|
+
status: response.status,
|
|
378
|
+
statusText: response.statusText,
|
|
379
|
+
headers,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isAnthropicApi(url) {
|
|
384
|
+
return url.hostname === "api.anthropic.com";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- plugin ----
|
|
388
|
+
|
|
389
|
+
export default {
|
|
390
|
+
id: "anthropic-oauth-v2",
|
|
391
|
+
async setup(ctx) {
|
|
392
|
+
await ctx.session.hook(
|
|
393
|
+
"http.request",
|
|
394
|
+
async (event) => {
|
|
395
|
+
let url;
|
|
396
|
+
try {
|
|
397
|
+
url = new URL(event.request.url);
|
|
398
|
+
} catch {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!isAnthropicApi(url)) return;
|
|
402
|
+
|
|
403
|
+
const access = await currentAccessToken();
|
|
404
|
+
if (!access) return; // not OAuth (API key user) — leave untouched
|
|
405
|
+
|
|
406
|
+
const headers = new Headers(event.request.headers);
|
|
407
|
+
headers.set("authorization", `Bearer ${access}`);
|
|
408
|
+
headers.set("anthropic-beta", mergeBetaHeaders(headers));
|
|
409
|
+
headers.set("user-agent", REQUEST_USER_AGENT);
|
|
410
|
+
headers.delete("x-api-key");
|
|
411
|
+
|
|
412
|
+
if (url.pathname === "/v1/messages" && !url.searchParams.has("beta")) {
|
|
413
|
+
url.searchParams.set("beta", "true");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let body;
|
|
417
|
+
if (event.request.method === "POST") {
|
|
418
|
+
const original = await event.request.clone().text();
|
|
419
|
+
body = rewriteRequestBody(original);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
event.request = new Request(url.toString(), {
|
|
423
|
+
method: event.request.method,
|
|
424
|
+
headers,
|
|
425
|
+
body,
|
|
426
|
+
duplex: "half",
|
|
427
|
+
signal: event.request.signal,
|
|
428
|
+
});
|
|
429
|
+
},
|
|
430
|
+
{ providerID: "anthropic" },
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await ctx.session.hook(
|
|
434
|
+
"http.response",
|
|
435
|
+
(event) => {
|
|
436
|
+
// Rewrite unless the response URL is present and provably non-Anthropic.
|
|
437
|
+
try {
|
|
438
|
+
const url = new URL(event.response.url);
|
|
439
|
+
if (!isAnthropicApi(url)) return;
|
|
440
|
+
} catch {
|
|
441
|
+
// No URL — rely on the providerID scope and rewrite anyway.
|
|
442
|
+
}
|
|
443
|
+
event.response = rewriteResponse(event.response);
|
|
444
|
+
},
|
|
445
|
+
{ providerID: "anthropic" },
|
|
446
|
+
);
|
|
447
|
+
},
|
|
448
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "op-anthropic-auth-v2",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"opencode2",
|
|
18
|
+
"anthropic",
|
|
19
|
+
"claude",
|
|
20
|
+
"oauth",
|
|
21
|
+
"auth",
|
|
22
|
+
"plugin"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "parzival1l <nandhu02111997@gmail.com>",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/parzival1l/op-anthropic-auth-v2.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/parzival1l/op-anthropic-auth-v2#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/parzival1l/op-anthropic-auth-v2/issues"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"check": "node --check index.js && npm test",
|
|
40
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
41
|
+
}
|
|
42
|
+
}
|