opencode-anthropic 0.0.0-stage → 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 +25 -0
- package/README.md +269 -2
- package/index.js +380 -0
- package/login.mjs +158 -0
- package/oauth.mjs +136 -0
- package/package.json +46 -4
- package/retry.mjs +46 -0
- package/tests/plugin.test.mjs +575 -0
- package/tools.mjs +174 -0
- package/transform.mjs +316 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiev
|
|
4
|
+
|
|
5
|
+
Portions of this software are derived from "opencode-anthropic-oauth"
|
|
6
|
+
(https://www.npmjs.com/package/opencode-anthropic-oauth), Copyright (c) 2026
|
|
7
|
+
shahidshabbir-se, released under the MIT License.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,270 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-anthropic
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode V2 plugin that brings **Claude Pro/Max OAuth** back to the Anthropic
|
|
4
|
+
provider.
|
|
5
|
+
|
|
6
|
+
OpenCode V2 removed Anthropic OAuth after a legal request from Anthropic, but
|
|
7
|
+
the plumbing is still there: OAuth credentials are stored and sent as
|
|
8
|
+
`Authorization: Bearer`. This plugin completes the picture:
|
|
9
|
+
|
|
10
|
+
- keeps Claude tokens fresh (refresh 10 minutes before expiry, single-flight,
|
|
11
|
+
with backoff and a cross-process fallback);
|
|
12
|
+
- mirrors the captured Claude Code 2.1.289 request profile: billing-header
|
|
13
|
+
system block, identity block, `metadata.user_id` (persistent random device
|
|
14
|
+
id), thinking enabled with `display: omitted`, `context_management`, SDK
|
|
15
|
+
telemetry headers, `x-claude-code-session-id`, and the matching beta flags;
|
|
16
|
+
- exposes OpenCode tools under Claude Code names (`shell` → `Bash`, `subagent`
|
|
17
|
+
→ `Agent`, ...) and translates Claude Code argument shapes (`file_path`,
|
|
18
|
+
`old_string`, `run_in_background`) back onto OpenCode schemas before they are
|
|
19
|
+
parsed;
|
|
20
|
+
- honours Anthropic's `retry-after` hint when OpenCode retries a rate limit.
|
|
21
|
+
|
|
22
|
+
It is a V2 port of the community
|
|
23
|
+
[`opencode-anthropic-oauth`](https://www.npmjs.com/package/opencode-anthropic-oauth)
|
|
24
|
+
(MIT) plugin, which only runs on OpenCode V1.
|
|
25
|
+
|
|
26
|
+
> **Warning:** Anthropic's Terms of Service say OAuth tokens from Free/Pro/Max
|
|
27
|
+
> plans must only be used by official clients. Since April 2026, third-party
|
|
28
|
+
> apps authenticated this way are metered against a separate prepaid **extra
|
|
29
|
+
> usage** balance (claude.ai/settings/usage) instead of the plan limits, and
|
|
30
|
+
> requests are blocked while that balance is empty. This plugin is a community
|
|
31
|
+
> workaround: it can stop working without notice and you use it at your own
|
|
32
|
+
> risk.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- OpenCode V2 (tested with 2.0.22);
|
|
37
|
+
- Node.js 20+ (for the login script and tests);
|
|
38
|
+
- A Claude Pro/Max subscription with an extra-usage balance, or an Anthropic
|
|
39
|
+
API key (API keys pass through the plugin untouched).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
From npm, add the package to the OpenCode config:
|
|
44
|
+
|
|
45
|
+
```jsonc
|
|
46
|
+
{
|
|
47
|
+
"plugins": [
|
|
48
|
+
"opencode-anthropic"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or use the CLI: `opencode plugin add opencode-anthropic`.
|
|
54
|
+
|
|
55
|
+
For a local checkout, point the config at the directory instead (global
|
|
56
|
+
config example, `~/.config/opencode/opencode.json`):
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"plugins": [
|
|
61
|
+
"../path/to/opencode-anthropic"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or copy the directory into `~/.config/opencode/plugins/opencode-anthropic/`,
|
|
67
|
+
where local plugins are auto-discovered. Restart the OpenCode service after
|
|
68
|
+
changing plugins: `opencode service restart`.
|
|
69
|
+
|
|
70
|
+
## Login
|
|
71
|
+
|
|
72
|
+
Use the regular OpenCode connect flow:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
/connect → Anthropic → Claude Pro/Max
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The plugin registers that method on the Anthropic integration: it opens the
|
|
79
|
+
claude.ai authorization page, asks for the code Claude shows, exchanges it,
|
|
80
|
+
and lets OpenCode store the credential. The bundled script does the same from
|
|
81
|
+
a terminal (useful for headless machines and CI):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node login.mjs # from a checkout
|
|
85
|
+
# or, when installed from npm:
|
|
86
|
+
opencode-anthropic login
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Add `--no-browser` to only print the authorization URL. The script opens the
|
|
90
|
+
browser flow, asks for the code Anthropic shows, exchanges it for tokens,
|
|
91
|
+
registers the credential in the running OpenCode server over its local HTTP
|
|
92
|
+
API (tokens never travel through process arguments), and saves a local cache
|
|
93
|
+
in `~/.local/share/opencode/opencode-anthropic.json` (mode `0600`).
|
|
94
|
+
|
|
95
|
+
Then pick an `anthropic/claude-*` model in OpenCode. The plugin refreshes the
|
|
96
|
+
token automatically.
|
|
97
|
+
|
|
98
|
+
## How it works
|
|
99
|
+
|
|
100
|
+
- **Strict gating.** The plugin only acts while an Anthropic **OAuth**
|
|
101
|
+
credential is the active connection. API keys, env credentials, and the
|
|
102
|
+
logged-out state pass through untouched — no surprises with `ps`, no stale
|
|
103
|
+
tokens resurrected after logout.
|
|
104
|
+
- **Connection-bound cache.** Refreshed tokens are stored in plugin storage
|
|
105
|
+
and in the token file, tagged with the OpenCode connection id they belong
|
|
106
|
+
to. Cached tokens from another connection (account switch, re-login) are
|
|
107
|
+
ignored.
|
|
108
|
+
- **Refresh.** Tokens within 10 minutes of expiry are refreshed before the
|
|
109
|
+
request. Concurrent requests share one refresh; a failed refresh backs off
|
|
110
|
+
for 5 minutes. If another process refreshed first, a still-valid cached
|
|
111
|
+
token is used instead of failing the request.
|
|
112
|
+
- **Request shaping.** `http.request` hook (provider `anthropic`): replaces
|
|
113
|
+
the authorization header with a fresh bearer token, removes `x-api-key`,
|
|
114
|
+
sets `user-agent: claude-cli/<version> (external, <entrypoint>)`,
|
|
115
|
+
`x-app: cli`, `x-claude-code-session-id` (a stable UUID per OpenCode
|
|
116
|
+
session), `anthropic-dangerous-direct-browser-access: true`, the
|
|
117
|
+
`x-stainless-*` telemetry headers and `accept: application/json`, and merges
|
|
118
|
+
the `anthropic-beta` flags.
|
|
119
|
+
- **Claude Code request profile.** The body is shaped like the captured
|
|
120
|
+
Claude Code 2.1.289 request: the billing-header line becomes `system[0]`,
|
|
121
|
+
the identity becomes `system[1]`, `metadata.user_id` carries a persistent
|
|
122
|
+
random device id plus the session id, and thinking-capable primary requests
|
|
123
|
+
get `thinking: { type: "enabled", budget_tokens: max_tokens - 1,
|
|
124
|
+
display: "omitted" }` with `context_management.edits` to match. Each piece is
|
|
125
|
+
individually switchable (see Configuration).
|
|
126
|
+
- **Response shaping.** `http.response` hook: parses SSE events, renames
|
|
127
|
+
`tool_use.name` back to the OpenCode name, and translates the arguments of
|
|
128
|
+
mapped tools. Argument fragments are held until the content block closes;
|
|
129
|
+
when nothing changes the original frames are passed through byte for byte,
|
|
130
|
+
and unparseable fragments fall back untouched.
|
|
131
|
+
- **Native `/connect`.** The plugin registers "Claude Pro/Max" as an OAuth
|
|
132
|
+
method on the Anthropic integration, so the normal connect UI (and the
|
|
133
|
+
integration OAuth API) works without the CLI script.
|
|
134
|
+
- **Rate limits.** A 429/503/529 response records Anthropic's `retry-after`
|
|
135
|
+
value; the session `retry` hook hands it back as the retry delay instead of
|
|
136
|
+
letting OpenCode hammer the endpoint.
|
|
137
|
+
|
|
138
|
+
### System prompt and tools
|
|
139
|
+
|
|
140
|
+
The default `prepend` mode builds the system array like Claude Code does: the
|
|
141
|
+
billing-header line (`x-anthropic-billing-header: cc_version=2.1.289.45c;
|
|
142
|
+
cc_entrypoint=cli;`) as the first block, then the identity ("You are a Claude
|
|
143
|
+
agent, built on Anthropic's Claude Agent SDK."), then the OpenCode prompt.
|
|
144
|
+
Set `ANTHROPIC_OAUTH_SYSTEM_IDENTITY` (or the `identity` option) to pin the
|
|
145
|
+
2025 wording or your own string.
|
|
146
|
+
|
|
147
|
+
`tools.mjs` holds an explicit compatibility table: only OpenCode tools with a
|
|
148
|
+
Claude Code counterpart are renamed, and each mapped tool also translates the
|
|
149
|
+
argument shapes a Claude Code model may produce:
|
|
150
|
+
|
|
151
|
+
| OpenCode | Exposed as | Arguments translated |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `read` | `Read` | `file_path` → `path`; drops `pages` |
|
|
154
|
+
| `write` | `Write` | `file_path` → `path` |
|
|
155
|
+
| `edit` | `Edit` | `file_path`, `old_string`, `new_string`, `replace_all` |
|
|
156
|
+
| `shell` | `Bash` | `run_in_background` → `background`; drops `description` |
|
|
157
|
+
| `grep` | `Grep` | `glob` → `include`, `head_limit` → `limit`, `-i` |
|
|
158
|
+
| `subagent` | `Agent` | `subagent_type` → `agent`, `run_in_background` → `background` |
|
|
159
|
+
| `question` | `AskUserQuestion` | `multiSelect` → `multiple` |
|
|
160
|
+
| `todowrite` | `TodoWrite` | drops `activeForm` |
|
|
161
|
+
| `webfetch` | `WebFetch` | drops `prompt` |
|
|
162
|
+
| `websearch` | `WebSearch` | drops domain filters |
|
|
163
|
+
| `skill` | `Skill` | `skill` → `id`; drops `args` |
|
|
164
|
+
| `glob` | `Glob` | — |
|
|
165
|
+
|
|
166
|
+
OpenCode-only tools (`compress`, `todoread`, `execute`/Code Mode) and unknown
|
|
167
|
+
tools (MCP) are left untouched. `toolAliases` can override any mapping, e.g.
|
|
168
|
+
`{ "subagent": "Task" }` for the pre-2.1 Claude Code name.
|
|
169
|
+
|
|
170
|
+
## Configuration
|
|
171
|
+
|
|
172
|
+
Options can be passed through the OpenCode config:
|
|
173
|
+
|
|
174
|
+
```jsonc
|
|
175
|
+
{
|
|
176
|
+
"plugins": [
|
|
177
|
+
{
|
|
178
|
+
"package": "../path/to/opencode-anthropic",
|
|
179
|
+
"options": {
|
|
180
|
+
"systemMode": "prepend",
|
|
181
|
+
"renameTools": true,
|
|
182
|
+
"toolAliases": { "my_tool": "Read" }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| Option | Default | Description |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| `systemMode` | `prepend` | `prepend`, `replace` (rewrites the first block), or `off` |
|
|
192
|
+
| `identity` | current Claude Code line | Overrides the injected identity string |
|
|
193
|
+
| `billingHeader` | `true` | Prepend the Claude Code billing-header system block |
|
|
194
|
+
| `metadata` | `true` | Send `metadata.user_id` with a persistent random device id |
|
|
195
|
+
| `thinking` | `true` | Add Claude Code thinking and `context_management` to primary Claude requests |
|
|
196
|
+
| `sdkHeaders` | `true` | Send the `x-stainless-*` telemetry headers |
|
|
197
|
+
| `renameTools` | `true` | Rename tools to Claude Code casing and back |
|
|
198
|
+
| `toolAliases` | `{}` | Extra or overriding tool-name mappings |
|
|
199
|
+
|
|
200
|
+
Environment variables (useful when the plugin is loaded as a directory):
|
|
201
|
+
`ANTHROPIC_OAUTH_SYSTEM_MODE`, `ANTHROPIC_OAUTH_SYSTEM_IDENTITY`,
|
|
202
|
+
`ANTHROPIC_OAUTH_BILLING_HEADER=0`, `ANTHROPIC_OAUTH_METADATA=0`,
|
|
203
|
+
`ANTHROPIC_OAUTH_THINKING=0`, `ANTHROPIC_OAUTH_SDK_HEADERS=0`,
|
|
204
|
+
`ANTHROPIC_OAUTH_RENAME_TOOLS=0`, `ANTHROPIC_OAUTH_TOOL_ALIASES` (JSON),
|
|
205
|
+
`ANTHROPIC_OAUTH_DUMP` (append every transformed OAuth request to a JSONL
|
|
206
|
+
file), `ANTHROPIC_CLI_VERSION`, `ANTHROPIC_CLI_BUILD`,
|
|
207
|
+
`ANTHROPIC_CLI_ENTRYPOINT`, `ANTHROPIC_BILLING_HEADER` (full line override),
|
|
208
|
+
`ANTHROPIC_SDK_VERSION`, `ANTHROPIC_NODE_VERSION`, `ANTHROPIC_BETA_FLAGS`,
|
|
209
|
+
`XDG_DATA_HOME`.
|
|
210
|
+
|
|
211
|
+
## Troubleshooting
|
|
212
|
+
|
|
213
|
+
- **401/403 on every request** — the refresh token was revoked or the login
|
|
214
|
+
expired. Run the login again.
|
|
215
|
+
- **Provider does not show up** — check `opencode auth list`; the
|
|
216
|
+
"Claude Pro/Max" credential must exist.
|
|
217
|
+
- **Tools do not execute** — try `ANTHROPIC_OAUTH_RENAME_TOOLS=0` and restart
|
|
218
|
+
the service.
|
|
219
|
+
- **Logout** — remove the credential with `/connect`; cached tokens are
|
|
220
|
+
ignored automatically because no OAuth connection is active anymore.
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
No dependencies; tests use the Node.js built-in runner and a mock token
|
|
225
|
+
server:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
node --test
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The repo includes a headless capture tool that runs the local `claude` binary
|
|
232
|
+
against a mock endpoint with an isolated config dir (no account, no real API):
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
npm run capture # capture + summarise
|
|
236
|
+
node scripts/claude-capture.mjs --compare a.json b.json
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`--compare` accepts a Claude capture and an OpenCode side (for example the
|
|
240
|
+
JSONL written by `ANTHROPIC_OAUTH_DUMP`).
|
|
241
|
+
|
|
242
|
+
## Known limitations
|
|
243
|
+
|
|
244
|
+
- Subscription OAuth usage is billed by Anthropic from the prepaid "extra
|
|
245
|
+
usage" balance (per-token) rather than the plan limits. The plugin cannot
|
|
246
|
+
change which pool a request is metered from; API keys are unaffected.
|
|
247
|
+
- The native `/connect` OAuth method is registered by this plugin; if the
|
|
248
|
+
registration fails (older OpenCode builds), `/connect` will not show
|
|
249
|
+
"Claude Pro/Max" and the bundled `login.mjs` script remains the fallback.
|
|
250
|
+
- The plugin targets a Claude Code compatibility profile
|
|
251
|
+
(`claude-cli/2.1.289`). Newer releases can be selected with
|
|
252
|
+
`ANTHROPIC_CLI_VERSION`, but the beta flags and tool list may need updating.
|
|
253
|
+
- Thinking is enabled by default for `claude-sonnet-*`/`claude-opus-*`
|
|
254
|
+
primary requests, mirroring Claude Code (`budget_tokens: max_tokens - 1`,
|
|
255
|
+
`display: "omitted"`). Disable with `ANTHROPIC_OAUTH_THINKING=0` or the
|
|
256
|
+
`thinking` option to keep OpenCode's own behaviour.
|
|
257
|
+
- The billing header mirrors the captured 2.1.289 line, which has no `cch`
|
|
258
|
+
hash; newer Claude Code builds may include one this plugin cannot compute.
|
|
259
|
+
- The full Claude Code system prompt and tool descriptions are not copied
|
|
260
|
+
(they are Anthropic's content and change every release): OpenCode's prompt
|
|
261
|
+
and schemas are sent with the identity/billing prefix instead. The device id
|
|
262
|
+
is a random per-install id, not a Claude Code install id.
|
|
263
|
+
- Refreshed tokens are kept in plugin storage and in the token file; OpenCode's
|
|
264
|
+
stored credential keeps the original access token until the next login.
|
|
265
|
+
|
|
266
|
+
## Credits and license
|
|
267
|
+
|
|
268
|
+
MIT. Ported from
|
|
269
|
+
[`opencode-anthropic-oauth`](https://github.com/shahidshabbir-se/opencode-anthropic-oauth)
|
|
270
|
+
(MIT) by shahidshabbir-se. See [LICENSE](LICENSE).
|
package/index.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// OpenCode V2 plugin: Claude Pro/Max OAuth support for the Anthropic provider.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode V2 removed Anthropic OAuth, but it still stores OAuth credentials
|
|
4
|
+
// and sends them as `Authorization: Bearer`. This plugin completes the
|
|
5
|
+
// picture: it keeps tokens fresh, injects the Claude Code identity and beta
|
|
6
|
+
// headers, and renames tools so Anthropic routes requests like official
|
|
7
|
+
// Claude Code.
|
|
8
|
+
//
|
|
9
|
+
// Token safety rules:
|
|
10
|
+
// - The plugin only acts while an Anthropic OAuth credential is the active
|
|
11
|
+
// connection. API keys and logged-out states pass through untouched.
|
|
12
|
+
// - Cached tokens are bound to the connection id they were issued for, so a
|
|
13
|
+
// logout or an account switch can never revive an old session from disk.
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
BETA_FLAGS,
|
|
19
|
+
BILLING_HEADER,
|
|
20
|
+
DEVICE_FILE,
|
|
21
|
+
SDK_HEADERS,
|
|
22
|
+
TOKENS_FILE,
|
|
23
|
+
USER_AGENT,
|
|
24
|
+
createAuthorizationRequest,
|
|
25
|
+
exchangeCodeForTokens,
|
|
26
|
+
refreshTokens,
|
|
27
|
+
} from "./oauth.mjs";
|
|
28
|
+
import {
|
|
29
|
+
buildToolNameMaps,
|
|
30
|
+
parseAliases,
|
|
31
|
+
sessionUUID,
|
|
32
|
+
transformRequestBody,
|
|
33
|
+
transformResponseStream,
|
|
34
|
+
translateToolInput,
|
|
35
|
+
} from "./transform.mjs";
|
|
36
|
+
import { createRateLimitState } from "./retry.mjs";
|
|
37
|
+
|
|
38
|
+
const REFRESH_BUFFER_MS = 10 * 60 * 1000;
|
|
39
|
+
const REFRESH_RETRY_MS = 5 * 60 * 1000;
|
|
40
|
+
const TOKEN_CACHE_MS = 30 * 1000;
|
|
41
|
+
export const LOGIN_HINT =
|
|
42
|
+
"opencode-anthropic: Claude Pro/Max login is missing or expired. Run `opencode-anthropic login` (or `node <plugin-dir>/login.mjs`) to sign in again.";
|
|
43
|
+
|
|
44
|
+
function isUsableTokens(value) {
|
|
45
|
+
return Boolean(
|
|
46
|
+
value && typeof value.access === "string" && value.access && typeof value.refresh === "string" && value.refresh,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readFileTokens() {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(TOKENS_FILE, "utf8"));
|
|
53
|
+
if (isUsableTokens(data)) return data;
|
|
54
|
+
} catch {}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeFileTokens(tokens) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(dirname(TOKENS_FILE), { recursive: true });
|
|
61
|
+
const temp = `${TOKENS_FILE}.tmp`;
|
|
62
|
+
writeFileSync(temp, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
63
|
+
renameSync(temp, TOKENS_FILE);
|
|
64
|
+
chmodSync(TOKENS_FILE, 0o600);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error("opencode-anthropic: failed to persist tokens to disk:", error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
id: "opencode-anthropic",
|
|
72
|
+
async setup(ctx) {
|
|
73
|
+
const options = {
|
|
74
|
+
systemMode: ctx.options?.systemMode ?? process.env.ANTHROPIC_OAUTH_SYSTEM_MODE ?? "prepend",
|
|
75
|
+
renameTools: ctx.options?.renameTools ?? process.env.ANTHROPIC_OAUTH_RENAME_TOOLS !== "0",
|
|
76
|
+
identity: ctx.options?.identity ?? process.env.ANTHROPIC_OAUTH_SYSTEM_IDENTITY,
|
|
77
|
+
billingHeader: ctx.options?.billingHeader ?? process.env.ANTHROPIC_OAUTH_BILLING_HEADER !== "0",
|
|
78
|
+
metadata: ctx.options?.metadata ?? process.env.ANTHROPIC_OAUTH_METADATA !== "0",
|
|
79
|
+
thinking: ctx.options?.thinking ?? process.env.ANTHROPIC_OAUTH_THINKING !== "0",
|
|
80
|
+
sdkHeaders: ctx.options?.sdkHeaders ?? process.env.ANTHROPIC_OAUTH_SDK_HEADERS !== "0",
|
|
81
|
+
toolAliases: {
|
|
82
|
+
...parseAliases(process.env.ANTHROPIC_OAUTH_TOOL_ALIASES),
|
|
83
|
+
...(ctx.options?.toolAliases ?? {}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const { forward, reverse } = buildToolNameMaps(options.toolAliases);
|
|
87
|
+
const requestOptions = {
|
|
88
|
+
systemMode: options.systemMode,
|
|
89
|
+
renameTools: options.renameTools,
|
|
90
|
+
identity: options.identity,
|
|
91
|
+
billing: options.billingHeader ? BILLING_HEADER : null,
|
|
92
|
+
forward,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
let cached = null;
|
|
96
|
+
let cachedAt = 0;
|
|
97
|
+
let refreshPromise = null;
|
|
98
|
+
let refreshFailedAt = 0;
|
|
99
|
+
const rateLimit = createRateLimitState();
|
|
100
|
+
const dumpFile = process.env.ANTHROPIC_OAUTH_DUMP;
|
|
101
|
+
|
|
102
|
+
let device = null;
|
|
103
|
+
async function deviceID() {
|
|
104
|
+
if (device) return device;
|
|
105
|
+
try {
|
|
106
|
+
const stored = await ctx.storage.get("device");
|
|
107
|
+
if (stored && /^[0-9a-f]{64}$/.test(String(stored.id))) return (device = stored.id);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error("opencode-anthropic: failed to read the device id from plugin storage:", error);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const file = JSON.parse(readFileSync(DEVICE_FILE, "utf8"));
|
|
113
|
+
if (file && /^[0-9a-f]{64}$/.test(String(file.id))) return (device = file.id);
|
|
114
|
+
} catch {}
|
|
115
|
+
device = randomBytes(32).toString("hex");
|
|
116
|
+
try {
|
|
117
|
+
await ctx.storage.set("device", { id: device });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error("opencode-anthropic: failed to persist the device id in plugin storage:", error);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(dirname(DEVICE_FILE), { recursive: true });
|
|
123
|
+
writeFileSync(DEVICE_FILE, JSON.stringify({ id: device }, null, 2), { mode: 0o600 });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("opencode-anthropic: failed to persist the device id on disk:", error);
|
|
126
|
+
}
|
|
127
|
+
return device;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function activeOAuthConnection() {
|
|
131
|
+
try {
|
|
132
|
+
const connection = await ctx.integration.connection.active("anthropic");
|
|
133
|
+
if (!connection || connection.type !== "credential") return null;
|
|
134
|
+
const value = await ctx.integration.connection.resolve(connection);
|
|
135
|
+
if (value?.type !== "oauth" || !isUsableTokens(value)) return null;
|
|
136
|
+
return { id: connection.id, tokens: value };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error("opencode-anthropic: failed to resolve the Anthropic connection:", error);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function boundTo(value, connectionID) {
|
|
144
|
+
return isUsableTokens(value) && value.connectionID === connectionID ? value : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function storedTokens() {
|
|
148
|
+
try {
|
|
149
|
+
return await ctx.storage.get("tokens");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error("opencode-anthropic: failed to read plugin storage:", error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function loadTokens(force = false) {
|
|
157
|
+
if (!force && cached && Date.now() - cachedAt < TOKEN_CACHE_MS) return cached;
|
|
158
|
+
cached = null;
|
|
159
|
+
cachedAt = Date.now();
|
|
160
|
+
const connection = await activeOAuthConnection();
|
|
161
|
+
if (!connection) return null;
|
|
162
|
+
const candidates = [
|
|
163
|
+
{ ...connection.tokens, connectionID: connection.id },
|
|
164
|
+
boundTo(await storedTokens(), connection.id),
|
|
165
|
+
boundTo(readFileTokens(), connection.id),
|
|
166
|
+
].filter(Boolean);
|
|
167
|
+
// The newest expiry wins: refreshed copies outrank the stale access
|
|
168
|
+
// token that OpenCode keeps in the credential record.
|
|
169
|
+
candidates.sort((a, b) => (b.expires ?? 0) - (a.expires ?? 0));
|
|
170
|
+
cached = candidates[0] ?? null;
|
|
171
|
+
return cached;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function persist(tokens) {
|
|
175
|
+
try {
|
|
176
|
+
await ctx.storage.set("tokens", tokens);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error("opencode-anthropic: failed to persist tokens in plugin storage:", error);
|
|
179
|
+
}
|
|
180
|
+
writeFileTokens(tokens);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function freshAccess() {
|
|
184
|
+
const tokens = await loadTokens();
|
|
185
|
+
if (!tokens) return null;
|
|
186
|
+
if ((tokens.expires ?? 0) > Date.now() + REFRESH_BUFFER_MS) return tokens.access;
|
|
187
|
+
if (refreshFailedAt && Date.now() - refreshFailedAt < REFRESH_RETRY_MS) return tokens.access;
|
|
188
|
+
if (!refreshPromise) {
|
|
189
|
+
refreshPromise = (async () => {
|
|
190
|
+
try {
|
|
191
|
+
const latest = (await loadTokens(true)) ?? tokens;
|
|
192
|
+
if ((latest.expires ?? 0) > Date.now() + REFRESH_BUFFER_MS) return latest.access;
|
|
193
|
+
const refreshed = await refreshTokens(latest.refresh);
|
|
194
|
+
const next = { ...latest, ...refreshed, connectionID: latest.connectionID };
|
|
195
|
+
cached = next;
|
|
196
|
+
cachedAt = Date.now();
|
|
197
|
+
refreshFailedAt = 0;
|
|
198
|
+
await persist(next);
|
|
199
|
+
return next.access;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
refreshFailedAt = Date.now();
|
|
202
|
+
// Another process may have refreshed first: re-read and use a
|
|
203
|
+
// still-valid token instead of failing the request.
|
|
204
|
+
const fallback = (await loadTokens(true)) ?? tokens;
|
|
205
|
+
if ((fallback.expires ?? 0) > Date.now()) return fallback.access;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
})().finally(() => {
|
|
209
|
+
refreshPromise = null;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return refreshPromise;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await ctx.session.hook(
|
|
216
|
+
"http.request",
|
|
217
|
+
async (event) => {
|
|
218
|
+
try {
|
|
219
|
+
const tokens = await loadTokens();
|
|
220
|
+
if (!tokens) return; // API key or logged out: leave the request alone.
|
|
221
|
+
const access = (await freshAccess()) ?? tokens.access;
|
|
222
|
+
const headers = event.request.headers;
|
|
223
|
+
headers.set("authorization", `Bearer ${access}`);
|
|
224
|
+
headers.delete("x-api-key");
|
|
225
|
+
headers.set("x-app", "cli");
|
|
226
|
+
headers.set("anthropic-dangerous-direct-browser-access", "true");
|
|
227
|
+
headers.set("user-agent", USER_AGENT);
|
|
228
|
+
const incoming = (headers.get("anthropic-beta") || "")
|
|
229
|
+
.split(",")
|
|
230
|
+
.map((flag) => flag.trim())
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
const required = BETA_FLAGS.split(",").map((flag) => flag.trim());
|
|
233
|
+
headers.set("anthropic-beta", [...new Set([...required, ...incoming])].join(","));
|
|
234
|
+
if (event.sessionID) headers.set("x-claude-code-session-id", sessionUUID(event.sessionID));
|
|
235
|
+
if (options.sdkHeaders) {
|
|
236
|
+
headers.set("accept", "application/json");
|
|
237
|
+
for (const [name, value] of Object.entries(SDK_HEADERS)) headers.set(name, value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const shapesBody =
|
|
241
|
+
options.systemMode !== "off" || options.renameTools || options.metadata || options.thinking;
|
|
242
|
+
if (shapesBody && event.request.body) {
|
|
243
|
+
const raw = await event.request.clone().text();
|
|
244
|
+
const metadataUserId =
|
|
245
|
+
options.metadata && event.sessionID
|
|
246
|
+
? JSON.stringify({
|
|
247
|
+
device_id: await deviceID(),
|
|
248
|
+
account_uuid: "",
|
|
249
|
+
session_id: sessionUUID(event.sessionID),
|
|
250
|
+
})
|
|
251
|
+
: null;
|
|
252
|
+
const transformed = transformRequestBody(raw, {
|
|
253
|
+
...requestOptions,
|
|
254
|
+
metadataUserId,
|
|
255
|
+
thinking: options.thinking && event.kind === "primary",
|
|
256
|
+
});
|
|
257
|
+
if (transformed !== raw) {
|
|
258
|
+
event.request = new Request(event.request.url, {
|
|
259
|
+
method: event.request.method,
|
|
260
|
+
headers,
|
|
261
|
+
body: transformed,
|
|
262
|
+
signal: event.request.signal,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (dumpFile) {
|
|
266
|
+
let body = transformed;
|
|
267
|
+
try {
|
|
268
|
+
body = JSON.parse(transformed);
|
|
269
|
+
} catch {}
|
|
270
|
+
try {
|
|
271
|
+
appendFileSync(
|
|
272
|
+
dumpFile,
|
|
273
|
+
`${JSON.stringify({ at: new Date().toISOString(), kind: event.kind, url: event.request.url, body })}\n`,
|
|
274
|
+
);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error("opencode-anthropic: failed to write ANTHROPIC_OAUTH_DUMP:", error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const message = String(error?.message ?? error);
|
|
282
|
+
if (message.includes("Token refresh failed")) console.error(`${LOGIN_HINT} (${message})`);
|
|
283
|
+
else console.error("opencode-anthropic: request hook failed:", error);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{ providerID: "anthropic" },
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await ctx.session.hook(
|
|
290
|
+
"http.response",
|
|
291
|
+
(event) => {
|
|
292
|
+
try {
|
|
293
|
+
rateLimit.note(event.response);
|
|
294
|
+
if (!options.renameTools || !event.response.ok || !event.response.body) return;
|
|
295
|
+
event.response = transformResponseStream(event.response, { reverse, translateInput: translateToolInput });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error("opencode-anthropic: response hook failed:", error);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{ providerID: "anthropic" },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Let OpenCode retry a rate-limited request using Anthropic's own
|
|
304
|
+
// `retry-after` hint instead of its default backoff.
|
|
305
|
+
try {
|
|
306
|
+
await ctx.session.hook(
|
|
307
|
+
"retry",
|
|
308
|
+
(event) => {
|
|
309
|
+
try {
|
|
310
|
+
const delay = rateLimit.delayMsFor(event?.error?.status);
|
|
311
|
+
if (delay === null) return;
|
|
312
|
+
event.decision = { retry: true, delay };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error("opencode-anthropic: retry hook failed:", error);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
{ providerID: "anthropic" },
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
console.warn("opencode-anthropic: retry hook unavailable in this OpenCode build:", error?.message ?? error);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Translate Claude Code argument shapes (file_path, old_string, ...)
|
|
324
|
+
// back onto OpenCode's tool schemas before execution.
|
|
325
|
+
try {
|
|
326
|
+
await ctx.tool.hook("execute.before", (event) => {
|
|
327
|
+
try {
|
|
328
|
+
event.input = translateToolInput(event.tool, event.input);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error("opencode-anthropic: tool hook failed:", error);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.warn("opencode-anthropic: tool hook unavailable in this OpenCode build:", error?.message ?? error);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Register Claude Pro/Max as a native OAuth method so the regular
|
|
338
|
+
// /connect flow works. The CLI login script stays as a fallback.
|
|
339
|
+
try {
|
|
340
|
+
await ctx.integration.transform((editor) => {
|
|
341
|
+
editor.method.update({
|
|
342
|
+
integrationID: "anthropic",
|
|
343
|
+
method: { id: "claude-pro-max", type: "oauth", label: "Claude Pro/Max" },
|
|
344
|
+
authorize: async () => {
|
|
345
|
+
const { url, verifier } = createAuthorizationRequest();
|
|
346
|
+
return {
|
|
347
|
+
mode: "code",
|
|
348
|
+
url,
|
|
349
|
+
instructions: "Authorize in your browser, then paste the code Claude shows.",
|
|
350
|
+
callback: async (code) => {
|
|
351
|
+
const tokens = await exchangeCodeForTokens(code, verifier);
|
|
352
|
+
return {
|
|
353
|
+
type: "oauth",
|
|
354
|
+
methodID: "claude-pro-max",
|
|
355
|
+
access: tokens.access,
|
|
356
|
+
refresh: tokens.refresh,
|
|
357
|
+
expires: tokens.expires,
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
refresh: async (credential) => {
|
|
363
|
+
const tokens = await refreshTokens(credential.refresh);
|
|
364
|
+
return {
|
|
365
|
+
...credential,
|
|
366
|
+
access: tokens.access,
|
|
367
|
+
refresh: tokens.refresh,
|
|
368
|
+
expires: tokens.expires,
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
} catch (error) {
|
|
374
|
+
console.warn(
|
|
375
|
+
"opencode-anthropic: could not register the native OAuth method; /connect will not show Claude Pro/Max. Use the login script instead:",
|
|
376
|
+
error?.message ?? error,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
};
|