@ex-machina/opencode-anthropic-auth 1.8.3 → 2.0.0-next.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/README.md +33 -12
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +108 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +137 -167
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
|
|
13
13
|
An [OpenCode](https://github.com/anomalyco/opencode) plugin that provides Anthropic OAuth authentication, enabling Claude Pro/Max users to use their subscription directly with OpenCode.
|
|
14
14
|
|
|
15
|
+
## Version compatibility
|
|
16
|
+
|
|
17
|
+
| Plugin version | OpenCode version | Package |
|
|
18
|
+
|-----------------|-------------------|---------------------------------------------|
|
|
19
|
+
| 2.x (this readme) | OpenCode v2 (beta plugin API) | `@ex-machina/opencode-anthropic-auth` |
|
|
20
|
+
| 1.x | OpenCode v1 | `@ex-machina/opencode-anthropic-auth@1` |
|
|
21
|
+
|
|
22
|
+
OpenCode v2's plugin API is still beta, and this plugin currently targets the `@opencode-ai/plugin@0.0.0-next-17444` prerelease of it — pin the plugin version and keep an eye on the changelog when bumping either side. If you're still on OpenCode v1, keep using a `1.x` release; v1 plugins are **not** loadable by OpenCode v2, and this v2 port is not loadable by OpenCode v1.
|
|
23
|
+
|
|
15
24
|
## Usage
|
|
16
25
|
|
|
17
26
|
Add the plugin to your OpenCode configuration:
|
|
18
27
|
|
|
19
28
|
```json
|
|
20
29
|
{
|
|
21
|
-
"
|
|
30
|
+
"plugins": ["@ex-machina/opencode-anthropic-auth"]
|
|
22
31
|
}
|
|
23
32
|
```
|
|
24
33
|
|
|
@@ -31,18 +40,22 @@ Add the plugin to your OpenCode configuration:
|
|
|
31
40
|
|
|
32
41
|
```json
|
|
33
42
|
{
|
|
34
|
-
"
|
|
43
|
+
"plugins": ["@ex-machina/opencode-anthropic-auth@<2.x.y-next.N>"]
|
|
35
44
|
}
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
The v2 line ships as prereleases on npm's `next` tag, so substitute a version that actually exists — `npm view @ex-machina/opencode-anthropic-auth dist-tags` shows the current one. Pin that exact version rather than tracking `@next`, which moves on every prerelease publish.
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
## Authentication Methods
|
|
41
50
|
|
|
42
51
|
- **Claude Pro/Max** - OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
|
|
43
|
-
- run the `/connect` command, select `Anthropic
|
|
44
|
-
- **
|
|
45
|
-
|
|
52
|
+
- run the `/connect` command, select `Anthropic` -> `Claude Pro/Max` and do OAuth
|
|
53
|
+
- **Manually enter API Key / `ANTHROPIC_API_KEY`** - Handled by OpenCode's built-in Anthropic integration, not by this plugin.
|
|
54
|
+
|
|
55
|
+
> [!NOTE]
|
|
56
|
+
> The v1 release of this plugin also offered a "Create an API Key" OAuth flow (via `console.anthropic.com`) that minted and stored an API key for you. OpenCode v2's plugin API does not yet support an OAuth authorization flow that ends in a stored API key, so that flow isn't available in this v2 release. Use manual API key entry (or `ANTHROPIC_API_KEY`) in the meantime — see [issue #203](https://github.com/ex-machina-co/opencode-anthropic-auth/issues/203) for status.
|
|
57
|
+
>
|
|
58
|
+
> OpenCode v2 continues to display Anthropic's API prices for these models even though requests authenticated through Claude Pro/Max use the subscription. Dynamic cost display is deferred until the beta plugin API can safely cancel the required connection event subscription.
|
|
46
59
|
|
|
47
60
|
## Configuration
|
|
48
61
|
|
|
@@ -51,7 +64,7 @@ The plugin supports the following environment variables:
|
|
|
51
64
|
| Variable | Description |
|
|
52
65
|
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
53
66
|
| `ANTHROPIC_BASE_URL` | Override the API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
|
|
54
|
-
| `ANTHROPIC_INSECURE` |
|
|
67
|
+
| `ANTHROPIC_INSECURE` | **Not supported under OpenCode v2.** OpenCode v2 plugin request hooks can rewrite a request but cannot disable TLS verification for it. If this is set, the plugin logs a warning and leaves TLS verification enabled — requests to a self-signed/untrusted `ANTHROPIC_BASE_URL` will fail. |
|
|
55
68
|
|
|
56
69
|
## How It Works
|
|
57
70
|
|
|
@@ -62,7 +75,6 @@ For Claude Pro/Max authentication, the plugin:
|
|
|
62
75
|
3. Automatically refreshes expired tokens
|
|
63
76
|
4. Injects the required OAuth headers and beta flags into API requests
|
|
64
77
|
5. Sanitizes the system prompt for compatibility (see below)
|
|
65
|
-
6. Zeros out model costs (since usage is covered by the subscription)
|
|
66
78
|
|
|
67
79
|
### System Prompt Sanitization
|
|
68
80
|
|
|
@@ -90,7 +102,14 @@ This does three things:
|
|
|
90
102
|
2. Symlinks the build output into `.opencode/plugins/` so OpenCode loads it as a local plugin
|
|
91
103
|
3. Starts `tsc --watch` for automatic rebuilds on source changes
|
|
92
104
|
|
|
93
|
-
After starting the dev script, restart OpenCode in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
|
|
105
|
+
After starting the dev script, restart OpenCode v2 (`opencode2`) in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
|
|
106
|
+
|
|
107
|
+
You can confirm the plugin loaded correctly via the OpenCode v2 API:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
opencode2 api get /api/plugin # should list "ex-machina.anthropic-auth"
|
|
111
|
+
opencode2 api get /api/integration # anthropic should offer a "Claude Pro/Max" OAuth method
|
|
112
|
+
```
|
|
94
113
|
|
|
95
114
|
Ctrl+C stops the watcher and cleans up the symlink. If the process was killed without cleanup (e.g. `kill -9`), you can manually remove the symlink:
|
|
96
115
|
|
|
@@ -103,13 +122,15 @@ bun run dev:clean
|
|
|
103
122
|
|
|
104
123
|
### Publishing
|
|
105
124
|
|
|
106
|
-
This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for
|
|
125
|
+
This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for contributor details.
|
|
107
126
|
|
|
108
127
|
```bash
|
|
109
128
|
bun change # create a changeset describing your changes
|
|
110
129
|
```
|
|
111
130
|
|
|
112
|
-
|
|
131
|
+
Changesets merged to a release branch cause CI to open a release PR; merging that PR publishes to npm. This repository runs two release trains — `main` publishes the v1 line to npm's `latest`, and `v2/main` publishes the v2 line to `next` as `2.x.y-next.N` prereleases.
|
|
132
|
+
|
|
133
|
+
Maintainers: see [RELEASING.md](RELEASING.md) for the full runbook, including how `main` is synced into `v2/main`.
|
|
113
134
|
|
|
114
135
|
## License
|
|
115
136
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -14,3 +14,19 @@ export type ExchangeResult = {
|
|
|
14
14
|
type: 'failed';
|
|
15
15
|
};
|
|
16
16
|
export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<ExchangeResult>;
|
|
17
|
+
export type RefreshResult = {
|
|
18
|
+
type: 'success';
|
|
19
|
+
refresh: string;
|
|
20
|
+
access: string;
|
|
21
|
+
expires: number;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'failed';
|
|
24
|
+
status: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Exchange a refresh token for a new access/refresh token pair.
|
|
28
|
+
* Retries transient (5xx, network) failures with exponential backoff;
|
|
29
|
+
* non-transient failures (e.g. 403 on a revoked/rotated-away token)
|
|
30
|
+
* are returned immediately as `{ type: 'failed' }`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function refreshToken(refreshTokenValue: string): Promise<RefreshResult>;
|
package/dist/auth.js
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { AUTHORIZE_URLS, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, TOKEN_URL, } from "./constants.js";
|
|
2
2
|
import { generatePKCE } from "./pkce.js";
|
|
3
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
4
|
+
function isTokenResponse(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null)
|
|
6
|
+
return false;
|
|
7
|
+
if (!('refresh_token' in value) || !('access_token' in value))
|
|
8
|
+
return false;
|
|
9
|
+
if (!('expires_in' in value))
|
|
10
|
+
return false;
|
|
11
|
+
return (typeof value.refresh_token === 'string' &&
|
|
12
|
+
value.refresh_token.length > 0 &&
|
|
13
|
+
typeof value.access_token === 'string' &&
|
|
14
|
+
value.access_token.length > 0 &&
|
|
15
|
+
typeof value.expires_in === 'number' &&
|
|
16
|
+
Number.isSafeInteger(value.expires_in) &&
|
|
17
|
+
value.expires_in > 0);
|
|
18
|
+
}
|
|
19
|
+
async function parseTokenResponse(response) {
|
|
20
|
+
try {
|
|
21
|
+
const value = await response.json();
|
|
22
|
+
if (!isTokenResponse(value))
|
|
23
|
+
return undefined;
|
|
24
|
+
const expires = Date.now() + value.expires_in * 1000;
|
|
25
|
+
if (!Number.isSafeInteger(expires))
|
|
26
|
+
return undefined;
|
|
27
|
+
return {
|
|
28
|
+
refresh: value.refresh_token,
|
|
29
|
+
access: value.access_token,
|
|
30
|
+
expires,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof SyntaxError)
|
|
35
|
+
return undefined;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
3
39
|
function generateState() {
|
|
4
40
|
return crypto.randomUUID().replace(/-/g, '');
|
|
5
41
|
}
|
|
@@ -50,12 +86,12 @@ async function exchangeCode(callback, verifier, redirectUri) {
|
|
|
50
86
|
type: 'failed',
|
|
51
87
|
};
|
|
52
88
|
}
|
|
53
|
-
const
|
|
89
|
+
const tokens = await parseTokenResponse(result);
|
|
90
|
+
if (!tokens)
|
|
91
|
+
return { type: 'failed' };
|
|
54
92
|
return {
|
|
55
93
|
type: 'success',
|
|
56
|
-
|
|
57
|
-
access: json.access_token,
|
|
58
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
94
|
+
...tokens,
|
|
59
95
|
};
|
|
60
96
|
}
|
|
61
97
|
export async function authorize(mode) {
|
|
@@ -91,3 +127,71 @@ export async function exchange(input, verifier, redirectUri, expectedState) {
|
|
|
91
127
|
}
|
|
92
128
|
return exchangeCode(callback, verifier, redirectUri);
|
|
93
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Exchange a refresh token for a new access/refresh token pair.
|
|
132
|
+
* Retries transient (5xx, network) failures with exponential backoff;
|
|
133
|
+
* non-transient failures (e.g. 403 on a revoked/rotated-away token)
|
|
134
|
+
* are returned immediately as `{ type: 'failed' }`.
|
|
135
|
+
*/
|
|
136
|
+
export async function refreshToken(refreshTokenValue) {
|
|
137
|
+
const maxRetries = 2;
|
|
138
|
+
const baseDelayMs = 500;
|
|
139
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
140
|
+
try {
|
|
141
|
+
if (attempt > 0) {
|
|
142
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
144
|
+
}
|
|
145
|
+
const response = await fetch(TOKEN_URL, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
Accept: 'application/json, text/plain, */*',
|
|
151
|
+
'User-Agent': 'axios/1.13.6',
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
grant_type: 'refresh_token',
|
|
155
|
+
refresh_token: refreshTokenValue,
|
|
156
|
+
client_id: CLIENT_ID,
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
161
|
+
await response.body?.cancel();
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
await response.body?.cancel();
|
|
165
|
+
return { type: 'failed', status: response.status };
|
|
166
|
+
}
|
|
167
|
+
const tokens = await parseTokenResponse(response);
|
|
168
|
+
if (!tokens) {
|
|
169
|
+
return { type: 'failed', status: response.status };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
type: 'success',
|
|
173
|
+
...tokens,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const isNetworkError = (typeof error === 'object' &&
|
|
178
|
+
error !== null &&
|
|
179
|
+
'name' in error &&
|
|
180
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')) ||
|
|
181
|
+
(error instanceof Error &&
|
|
182
|
+
(error.message.includes('fetch failed') ||
|
|
183
|
+
('code' in error &&
|
|
184
|
+
(error.code === 'ECONNRESET' ||
|
|
185
|
+
error.code === 'ECONNREFUSED' ||
|
|
186
|
+
error.code === 'ETIMEDOUT' ||
|
|
187
|
+
error.code === 'UND_ERR_CONNECT_TIMEOUT'))));
|
|
188
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Unreachable — each iteration either returns or throws.
|
|
195
|
+
// Kept as a TypeScript exhaustiveness guard.
|
|
196
|
+
throw new Error('Token refresh exhausted all retries');
|
|
197
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
declare const _default: Plugin.Plugin;
|
|
3
|
+
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,175 +1,145 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
import { authorize, exchange, refreshToken } from "./auth.js";
|
|
3
|
+
import { REQUIRED_BETAS } from "./constants.js";
|
|
3
4
|
import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
|
|
4
|
-
|
|
5
|
+
const PLUGIN_ID = 'ex-machina.anthropic-auth';
|
|
6
|
+
const INTEGRATION_ID = 'anthropic';
|
|
7
|
+
const REFRESH_CACHE_GRACE_MS = 30_000;
|
|
8
|
+
// `methodID` is a branded `Integration.MethodID` at the type level (a
|
|
9
|
+
// compile-time-only tag — there's no runtime representation), so a plain
|
|
10
|
+
// string literal needs a cast to satisfy the branded field.
|
|
11
|
+
const METHOD_ID = 'claude-max';
|
|
12
|
+
function toCredential(exchanged) {
|
|
5
13
|
return {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
type: 'oauth',
|
|
15
|
+
methodID: METHOD_ID,
|
|
16
|
+
refresh: exchanged.refresh,
|
|
17
|
+
access: exchanged.access,
|
|
18
|
+
expires: exchanged.expires,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function resolveActiveOAuth(ctx) {
|
|
22
|
+
const connection = await ctx.integration.connection.active(INTEGRATION_ID);
|
|
23
|
+
if (!connection)
|
|
24
|
+
return undefined;
|
|
25
|
+
const credential = await ctx.integration.connection.resolve(connection);
|
|
26
|
+
if (credential?.type === 'oauth' && credential.methodID === METHOD_ID) {
|
|
27
|
+
return credential;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function warnIfInsecureUnsupported() {
|
|
32
|
+
if (!isInsecure())
|
|
33
|
+
return;
|
|
34
|
+
console.warn('[ex-machina.anthropic-auth] ANTHROPIC_INSECURE is set, but OpenCode v2 ' +
|
|
35
|
+
'plugin request hooks cannot disable TLS verification for a custom ' +
|
|
36
|
+
'ANTHROPIC_BASE_URL endpoint. TLS verification remains enabled — ' +
|
|
37
|
+
'requests to an untrusted/self-signed endpoint will fail.');
|
|
38
|
+
}
|
|
39
|
+
function isTransformedOAuthRequest(request) {
|
|
40
|
+
const betas = new Set((request.headers.get('anthropic-beta') ?? '')
|
|
41
|
+
.split(',')
|
|
42
|
+
.map((beta) => beta.trim()));
|
|
43
|
+
return (request.headers.get('authorization')?.startsWith('Bearer ') === true &&
|
|
44
|
+
REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
|
|
45
|
+
new URL(request.url).searchParams.get('beta') === 'true');
|
|
46
|
+
}
|
|
47
|
+
export default Plugin.define({
|
|
48
|
+
id: PLUGIN_ID,
|
|
49
|
+
setup: async (ctx) => {
|
|
50
|
+
warnIfInsecureUnsupported();
|
|
51
|
+
// Retain successful refreshes for this plugin generation so a host call
|
|
52
|
+
// holding the rotated token cannot submit it again before persistence.
|
|
53
|
+
const refreshInFlight = new Map();
|
|
54
|
+
const refreshCredential = async (credential) => {
|
|
55
|
+
const existing = refreshInFlight.get(credential.refresh);
|
|
56
|
+
if (existing)
|
|
57
|
+
return existing;
|
|
58
|
+
const pending = (async () => {
|
|
59
|
+
const result = await refreshToken(credential.refresh);
|
|
60
|
+
if (result.type === 'failed') {
|
|
61
|
+
throw new Error(`Anthropic token refresh failed: ${result.status}`);
|
|
62
|
+
}
|
|
63
|
+
return toCredential(result);
|
|
64
|
+
})();
|
|
65
|
+
refreshInFlight.set(credential.refresh, pending);
|
|
66
|
+
try {
|
|
67
|
+
const rotated = await pending;
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
if (refreshInFlight.get(credential.refresh) === pending) {
|
|
70
|
+
refreshInFlight.delete(credential.refresh);
|
|
21
71
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
72
|
+
}, REFRESH_CACHE_GRACE_MS);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
return rotated;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (refreshInFlight.get(credential.refresh) === pending) {
|
|
78
|
+
refreshInFlight.delete(credential.refresh);
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await ctx.integration.transform((draft) => {
|
|
84
|
+
draft.method.update({
|
|
85
|
+
integrationID: INTEGRATION_ID,
|
|
86
|
+
method: {
|
|
87
|
+
id: METHOD_ID,
|
|
88
|
+
type: 'oauth',
|
|
89
|
+
label: 'Claude Pro/Max',
|
|
90
|
+
},
|
|
91
|
+
authorize: async () => {
|
|
92
|
+
const result = await authorize('max');
|
|
25
93
|
return {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const maxRetries = 2;
|
|
35
|
-
const baseDelayMs = 500;
|
|
36
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
37
|
-
try {
|
|
38
|
-
if (attempt > 0) {
|
|
39
|
-
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
40
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
41
|
-
}
|
|
42
|
-
// Re-read auth to get the latest refresh token.
|
|
43
|
-
// The outer `auth` snapshot may be stale if tokens
|
|
44
|
-
// were rotated since the fetch() call was made.
|
|
45
|
-
const freshAuth = await getAuth();
|
|
46
|
-
const response = await fetch(TOKEN_URL, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: {
|
|
49
|
-
'Content-Type': 'application/json',
|
|
50
|
-
Accept: 'application/json, text/plain, */*',
|
|
51
|
-
'User-Agent': 'axios/1.13.6',
|
|
52
|
-
},
|
|
53
|
-
body: JSON.stringify({
|
|
54
|
-
grant_type: 'refresh_token',
|
|
55
|
-
refresh_token: freshAuth.refresh,
|
|
56
|
-
client_id: CLIENT_ID,
|
|
57
|
-
}),
|
|
58
|
-
});
|
|
59
|
-
if (!response.ok) {
|
|
60
|
-
if (response.status >= 500 && attempt < maxRetries) {
|
|
61
|
-
await response.body?.cancel();
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const body = await response.text().catch(() => '');
|
|
65
|
-
throw new Error(`Token refresh failed: ${response.status} — ${body}`);
|
|
66
|
-
}
|
|
67
|
-
const json = (await response.json());
|
|
68
|
-
// biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
|
|
69
|
-
await client.auth.set({
|
|
70
|
-
path: {
|
|
71
|
-
id: 'anthropic',
|
|
72
|
-
},
|
|
73
|
-
body: {
|
|
74
|
-
type: 'oauth',
|
|
75
|
-
refresh: json.refresh_token,
|
|
76
|
-
access: json.access_token,
|
|
77
|
-
expires: Date.now() + json.expires_in * 1000,
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
return json.access_token;
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
const isNetworkError = error instanceof Error &&
|
|
84
|
-
(error.message.includes('fetch failed') ||
|
|
85
|
-
('code' in error &&
|
|
86
|
-
(error.code === 'ECONNRESET' ||
|
|
87
|
-
error.code === 'ECONNREFUSED' ||
|
|
88
|
-
error.code === 'ETIMEDOUT' ||
|
|
89
|
-
error.code === 'UND_ERR_CONNECT_TIMEOUT')));
|
|
90
|
-
if (attempt < maxRetries && isNetworkError) {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
throw error;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Unreachable — each iteration either returns or throws.
|
|
97
|
-
// Kept as a TypeScript exhaustiveness guard.
|
|
98
|
-
throw new Error('Token refresh exhausted all retries');
|
|
99
|
-
})().finally(() => {
|
|
100
|
-
refreshPromise = null;
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
auth.access = await refreshPromise;
|
|
104
|
-
}
|
|
105
|
-
const requestHeaders = mergeHeaders(input, init);
|
|
106
|
-
// biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
|
|
107
|
-
setOAuthHeaders(requestHeaders, auth.access);
|
|
108
|
-
let body = init?.body;
|
|
109
|
-
if (body && typeof body === 'string') {
|
|
110
|
-
body = rewriteRequestBody(body);
|
|
94
|
+
url: result.url,
|
|
95
|
+
instructions: 'Paste the authorization code here:',
|
|
96
|
+
mode: 'code',
|
|
97
|
+
callback: async (code) => {
|
|
98
|
+
const exchanged = await exchange(code, result.verifier, result.redirectUri, result.state);
|
|
99
|
+
if (exchanged.type === 'failed') {
|
|
100
|
+
throw new Error('Failed to exchange the Claude Pro/Max authorization code. ' +
|
|
101
|
+
'Double-check that you pasted the full code and try again.');
|
|
111
102
|
}
|
|
112
|
-
|
|
113
|
-
const response = await fetch(rewritten.input, {
|
|
114
|
-
...init,
|
|
115
|
-
body,
|
|
116
|
-
headers: requestHeaders,
|
|
117
|
-
...(isInsecure() && { tls: { rejectUnauthorized: false } }),
|
|
118
|
-
});
|
|
119
|
-
return createStrippedStream(response);
|
|
103
|
+
return toCredential(exchanged);
|
|
120
104
|
},
|
|
121
105
|
};
|
|
122
|
-
}
|
|
123
|
-
return {};
|
|
124
|
-
},
|
|
125
|
-
methods: [
|
|
126
|
-
{
|
|
127
|
-
label: 'Claude Pro/Max',
|
|
128
|
-
type: 'oauth',
|
|
129
|
-
authorize: async () => {
|
|
130
|
-
const result = await authorize('max');
|
|
131
|
-
return {
|
|
132
|
-
url: result.url,
|
|
133
|
-
instructions: 'Paste the authorization code here:',
|
|
134
|
-
method: 'code',
|
|
135
|
-
callback: async (code) => {
|
|
136
|
-
return exchange(code, result.verifier, result.redirectUri, result.state);
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
},
|
|
140
106
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
107
|
+
refresh: refreshCredential,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
await ctx.session.hook('http.request', async (event) => {
|
|
111
|
+
if (event.model.providerID !== INTEGRATION_ID)
|
|
112
|
+
return;
|
|
113
|
+
const credential = await resolveActiveOAuth(ctx);
|
|
114
|
+
if (!credential)
|
|
115
|
+
return;
|
|
116
|
+
const request = event.request;
|
|
117
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
118
|
+
const bodyText = hasBody ? await request.clone().text() : undefined;
|
|
119
|
+
const rewrittenBody = bodyText !== undefined ? rewriteRequestBody(bodyText) : undefined;
|
|
120
|
+
const headers = mergeHeaders(request);
|
|
121
|
+
setOAuthHeaders(headers, credential.access);
|
|
122
|
+
if (rewrittenBody !== undefined)
|
|
123
|
+
headers.delete('content-length');
|
|
124
|
+
const { input: rewrittenInput } = rewriteUrl(request.url);
|
|
125
|
+
const url = typeof rewrittenInput === 'string'
|
|
126
|
+
? rewrittenInput
|
|
127
|
+
: rewrittenInput instanceof Request
|
|
128
|
+
? rewrittenInput.url
|
|
129
|
+
: rewrittenInput.toString();
|
|
130
|
+
event.request = new Request(url, {
|
|
131
|
+
method: request.method,
|
|
132
|
+
headers,
|
|
133
|
+
body: rewrittenBody,
|
|
134
|
+
signal: request.signal,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
await ctx.session.hook('http.response', (event) => {
|
|
138
|
+
if (event.model.providerID !== INTEGRATION_ID)
|
|
139
|
+
return;
|
|
140
|
+
if (!isTransformedOAuthRequest(event.request))
|
|
141
|
+
return;
|
|
142
|
+
event.response = createStrippedStream(event.response);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ex-machina/opencode-anthropic-auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-next.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"repository": {
|
|
5
6
|
"type": "git",
|
|
6
7
|
"url": "https://github.com/ex-machina-co/opencode-anthropic-auth"
|
|
@@ -27,16 +28,16 @@
|
|
|
27
28
|
"format:check": "biome format .",
|
|
28
29
|
"lint": "biome lint --error-on-warnings .",
|
|
29
30
|
"change": "changeset",
|
|
30
|
-
"release": "bun run build && bun change publish"
|
|
31
|
+
"release": "bun run build && bun change publish",
|
|
32
|
+
"release:next": "bun scripts/validate-next-release.ts && bun run release"
|
|
31
33
|
},
|
|
32
|
-
"
|
|
33
|
-
"@opencode-ai/plugin": "
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@opencode-ai/plugin": "0.0.0-next-17444"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@biomejs/biome": "2.5.2",
|
|
37
39
|
"@changesets/changelog-github": "^0.7.0",
|
|
38
40
|
"@changesets/cli": "^2.31.0",
|
|
39
|
-
"@opencode-ai/plugin": "1.17.13",
|
|
40
41
|
"@tsconfig/bun": "1.0.10",
|
|
41
42
|
"@types/bun": "1.3.14",
|
|
42
43
|
"dedent": "^1.7.2",
|