@ex-machina/opencode-anthropic-auth 1.8.2 → 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 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
- "plugin": ["@ex-machina/opencode-anthropic-auth"]
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
- "plugin": ["@ex-machina/opencode-anthropic-auth@1.8.1"]
43
+ "plugins": ["@ex-machina/opencode-anthropic-auth@<2.x.y-next.N>"]
35
44
  }
36
45
  ```
37
46
 
38
- ## Authentication Methods
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
- The plugin provides three authentication options:
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 (API key)` -> `Claude Pro/Max` and do OAuth
44
- - **Create an API Key** - OAuth flow via `console.anthropic.com` that creates an API key on your behalf.
45
- - **Manually enter API Key** - Standard API key entry for users who already have one.
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` | Set to `1` or `true` to skip TLS certificate verification. Only effective when `ANTHROPIC_BASE_URL` is also set. |
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 more details.
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
- When changesets are merged to `main`, CI will automatically open a release PR. Merging that PR publishes to npm.
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 json = (await result.json());
89
+ const tokens = await parseTokenResponse(result);
90
+ if (!tokens)
91
+ return { type: 'failed' };
54
92
  return {
55
93
  type: 'success',
56
- refresh: json.refresh_token,
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 type { Plugin } from '@opencode-ai/plugin';
2
- export declare const AnthropicAuthPlugin: Plugin;
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 { authorize, exchange } from "./auth.js";
2
- import { CLIENT_ID, TOKEN_URL } from "./constants.js";
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
- export const AnthropicAuthPlugin = async ({ client }) => {
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
- auth: {
7
- provider: 'anthropic',
8
- async loader(getAuth, provider) {
9
- const auth = await getAuth();
10
- if (auth.type === 'oauth') {
11
- // zero out cost for max plan
12
- for (const model of Object.values(provider.models)) {
13
- model.cost = {
14
- input: 0,
15
- output: 0,
16
- cache: {
17
- read: 0,
18
- write: 0,
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
- // Shared inflight refresh promise — prevents concurrent token refreshes
23
- // from racing against each other (and causing 401 cascades with token rotation)
24
- let refreshPromise = null;
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
- apiKey: '',
27
- async fetch(input, init) {
28
- const auth = await getAuth();
29
- if (auth.type !== 'oauth')
30
- return fetch(input, init);
31
- if (!auth.access || !auth.expires || auth.expires < Date.now()) {
32
- if (!refreshPromise) {
33
- refreshPromise = (async () => {
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
- const rewritten = rewriteUrl(input);
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
- label: 'Create an API Key',
143
- type: 'oauth',
144
- authorize: async () => {
145
- const result = await authorize('console');
146
- return {
147
- url: result.url,
148
- instructions: 'Paste the authorization code here:',
149
- method: 'code',
150
- callback: async (code) => {
151
- const credentials = await exchange(code, result.verifier, result.redirectUri, result.state);
152
- if (credentials.type === 'failed')
153
- return credentials;
154
- const apiKey = await fetch(`https://api.anthropic.com/api/oauth/claude_cli/create_api_key`, {
155
- method: 'POST',
156
- headers: {
157
- 'Content-Type': 'application/json',
158
- authorization: `Bearer ${credentials.access}`,
159
- },
160
- }).then((r) => r.json());
161
- return { type: 'success', key: apiKey.raw_key };
162
- },
163
- };
164
- },
165
- },
166
- {
167
- provider: 'anthropic',
168
- label: 'Manually enter API Key',
169
- type: 'api',
170
- },
171
- ],
172
- },
173
- // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks
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
+ });
@@ -1,3 +1,5 @@
1
+ export declare const MAX_SSE_LINE_BYTES: number;
2
+ export declare const MAX_JSON_TOOL_NAME_BYTES = 1024;
1
3
  export type FetchInput = string | URL | Request;
2
4
  /**
3
5
  * Merge headers from a Request object and/or a RequestInit headers value
package/dist/transform.js CHANGED
@@ -1,5 +1,208 @@
1
1
  import { buildBillingHeaderValue } from "./cch.js";
2
2
  import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
3
+ // Bound an incomplete SSE line so malformed streams cannot grow memory forever.
4
+ export const MAX_SSE_LINE_BYTES = 5 * 1024 * 1024;
5
+ function headersAfterBodyTransform(source) {
6
+ const headers = new Headers(source);
7
+ for (const name of [
8
+ 'content-digest',
9
+ 'content-encoding',
10
+ 'content-length',
11
+ 'content-md5',
12
+ 'content-range',
13
+ 'digest',
14
+ 'etag',
15
+ ]) {
16
+ headers.delete(name);
17
+ }
18
+ return headers;
19
+ }
20
+ const JSON_NAME_KEY_SUFFIX = new TextEncoder().encode('name"');
21
+ const JSON_TOOL_PREFIX = new TextEncoder().encode(TOOL_PREFIX);
22
+ const UTF8_ENCODER = new TextEncoder();
23
+ const UTF8_FATAL_DECODER = new TextDecoder('utf-8', { fatal: true });
24
+ export const MAX_JSON_TOOL_NAME_BYTES = 1024;
25
+ function isJsonWhitespace(byte) {
26
+ return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
27
+ }
28
+ /**
29
+ * Rewrite JSON `name` string values without buffering the whole document.
30
+ * Only a bounded tool-name candidate is retained across chunks; all document
31
+ * content outside that string value is emitted immediately.
32
+ */
33
+ function createJsonToolNameStream(body) {
34
+ let state = 'outside';
35
+ let held = [];
36
+ let candidateIndex = 0;
37
+ let escaped = false;
38
+ const enterStringAfter = (byte) => {
39
+ if (byte === 0x22) {
40
+ state = 'outside';
41
+ escaped = false;
42
+ return;
43
+ }
44
+ state = 'string';
45
+ escaped = byte === 0x5c;
46
+ };
47
+ return body.pipeThrough(new TransformStream({
48
+ transform(chunk, controller) {
49
+ const output = new Uint8Array(chunk.byteLength + 32);
50
+ let outputLength = 0;
51
+ const write = (byte) => {
52
+ output[outputLength++] = byte;
53
+ };
54
+ const enqueueOutput = () => {
55
+ if (outputLength === 0)
56
+ return;
57
+ controller.enqueue(output.slice(0, outputLength));
58
+ outputLength = 0;
59
+ };
60
+ const writeHeld = () => {
61
+ for (const byte of held)
62
+ write(byte);
63
+ held = [];
64
+ };
65
+ const processOutside = (byte) => {
66
+ if (byte === 0x22) {
67
+ held = [byte];
68
+ candidateIndex = 0;
69
+ state = 'key-candidate';
70
+ return;
71
+ }
72
+ write(byte);
73
+ };
74
+ for (const byte of chunk) {
75
+ if (state === 'outside') {
76
+ processOutside(byte);
77
+ continue;
78
+ }
79
+ if (state === 'key-candidate') {
80
+ if (byte === JSON_NAME_KEY_SUFFIX[candidateIndex]) {
81
+ held.push(byte);
82
+ candidateIndex++;
83
+ if (candidateIndex === JSON_NAME_KEY_SUFFIX.byteLength) {
84
+ writeHeld();
85
+ state = 'after-name-key';
86
+ }
87
+ continue;
88
+ }
89
+ writeHeld();
90
+ write(byte);
91
+ enterStringAfter(byte);
92
+ continue;
93
+ }
94
+ if (state === 'string') {
95
+ write(byte);
96
+ if (escaped) {
97
+ escaped = false;
98
+ }
99
+ else if (byte === 0x5c) {
100
+ escaped = true;
101
+ }
102
+ else if (byte === 0x22) {
103
+ state = 'outside';
104
+ }
105
+ continue;
106
+ }
107
+ if (state === 'after-name-key') {
108
+ if (isJsonWhitespace(byte)) {
109
+ write(byte);
110
+ }
111
+ else if (byte === 0x3a) {
112
+ write(byte);
113
+ state = 'after-colon';
114
+ }
115
+ else {
116
+ processOutside(byte);
117
+ }
118
+ continue;
119
+ }
120
+ if (state === 'after-colon') {
121
+ if (isJsonWhitespace(byte)) {
122
+ write(byte);
123
+ }
124
+ else if (byte === 0x22) {
125
+ write(byte);
126
+ held = [];
127
+ candidateIndex = 0;
128
+ state = 'prefix-candidate';
129
+ }
130
+ else {
131
+ processOutside(byte);
132
+ }
133
+ continue;
134
+ }
135
+ if (state === 'prefix-candidate') {
136
+ if (byte === JSON_TOOL_PREFIX[candidateIndex]) {
137
+ held.push(byte);
138
+ candidateIndex++;
139
+ if (candidateIndex === JSON_TOOL_PREFIX.byteLength) {
140
+ held = [];
141
+ candidateIndex = 0;
142
+ escaped = false;
143
+ state = 'tool-name-candidate';
144
+ }
145
+ continue;
146
+ }
147
+ writeHeld();
148
+ write(byte);
149
+ enterStringAfter(byte);
150
+ continue;
151
+ }
152
+ if (escaped) {
153
+ held.push(byte);
154
+ escaped = false;
155
+ if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
156
+ throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
157
+ }
158
+ continue;
159
+ }
160
+ if (byte === 0x5c) {
161
+ held.push(byte);
162
+ escaped = true;
163
+ if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
164
+ throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
165
+ }
166
+ continue;
167
+ }
168
+ if (byte === 0x22) {
169
+ let replacement;
170
+ if (held.length === 0) {
171
+ replacement = JSON_TOOL_PREFIX;
172
+ }
173
+ else {
174
+ try {
175
+ replacement = UTF8_ENCODER.encode(unprefixName(UTF8_FATAL_DECODER.decode(Uint8Array.from(held))));
176
+ }
177
+ catch {
178
+ replacement = Uint8Array.from([...JSON_TOOL_PREFIX, ...held]);
179
+ }
180
+ }
181
+ enqueueOutput();
182
+ controller.enqueue(replacement);
183
+ write(byte);
184
+ held = [];
185
+ candidateIndex = 0;
186
+ state = 'outside';
187
+ continue;
188
+ }
189
+ held.push(byte);
190
+ if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
191
+ throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
192
+ }
193
+ }
194
+ enqueueOutput();
195
+ },
196
+ flush(controller) {
197
+ const trailing = Uint8Array.from(state === 'tool-name-candidate'
198
+ ? [...JSON_TOOL_PREFIX, ...held]
199
+ : held);
200
+ if (trailing.byteLength === 0)
201
+ return;
202
+ controller.enqueue(trailing);
203
+ },
204
+ }));
205
+ }
3
206
  /**
4
207
  * Prefix a tool name with TOOL_PREFIX and uppercase the first character.
5
208
  * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
@@ -289,26 +492,83 @@ export function rewriteRequestBody(body) {
289
492
  * Create a streaming response that strips the tool prefix from tool names.
290
493
  */
291
494
  export function createStrippedStream(response) {
495
+ const mediaType = response.headers
496
+ .get('content-type')
497
+ ?.split(';', 1)[0]
498
+ ?.trim()
499
+ .toLowerCase();
292
500
  if (!response.body)
293
501
  return response;
294
- const reader = response.body.getReader();
502
+ if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
503
+ const stream = createJsonToolNameStream(response.body);
504
+ const headers = headersAfterBodyTransform(response.headers);
505
+ return new Response(stream, {
506
+ status: response.status,
507
+ statusText: response.statusText,
508
+ headers,
509
+ });
510
+ }
511
+ if (mediaType !== 'text/event-stream')
512
+ return response;
295
513
  const decoder = new TextDecoder();
296
514
  const encoder = new TextEncoder();
297
- const stream = new ReadableStream({
298
- async pull(controller) {
299
- const { done, value } = await reader.read();
300
- if (done) {
301
- controller.close();
515
+ let pending = new Uint8Array(0);
516
+ let pendingLength = 0;
517
+ const appendPending = (bytes) => {
518
+ const requiredLength = pendingLength + bytes.byteLength;
519
+ if (requiredLength > MAX_SSE_LINE_BYTES) {
520
+ throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
521
+ }
522
+ if (requiredLength > pending.byteLength) {
523
+ let capacity = Math.max(1024, pending.byteLength);
524
+ while (capacity < requiredLength) {
525
+ capacity = Math.min(MAX_SSE_LINE_BYTES, capacity * 2);
526
+ }
527
+ const expanded = new Uint8Array(capacity);
528
+ expanded.set(pending.subarray(0, pendingLength));
529
+ pending = expanded;
530
+ }
531
+ pending.set(bytes, pendingLength);
532
+ pendingLength = requiredLength;
533
+ };
534
+ const stream = response.body.pipeThrough(new TransformStream({
535
+ transform(chunk, controller) {
536
+ let lastLineBreak = -1;
537
+ let lineLength = pendingLength;
538
+ for (let index = 0; index < chunk.byteLength; index++) {
539
+ if (chunk[index] === 0x0a || chunk[index] === 0x0d) {
540
+ lastLineBreak = index;
541
+ lineLength = 0;
542
+ }
543
+ else {
544
+ lineLength++;
545
+ if (lineLength > MAX_SSE_LINE_BYTES) {
546
+ throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
547
+ }
548
+ }
549
+ }
550
+ if (lastLineBreak < 0) {
551
+ appendPending(chunk);
302
552
  return;
303
553
  }
304
- let text = decoder.decode(value, { stream: true });
305
- text = stripToolPrefix(text);
306
- controller.enqueue(encoder.encode(text));
554
+ const completeLines = decoder.decode(pending.subarray(0, pendingLength), {
555
+ stream: true,
556
+ }) + decoder.decode(chunk.subarray(0, lastLineBreak + 1));
557
+ pendingLength = 0;
558
+ appendPending(chunk.subarray(lastLineBreak + 1));
559
+ controller.enqueue(encoder.encode(stripToolPrefix(completeLines)));
307
560
  },
308
- });
561
+ flush(controller) {
562
+ const trailing = decoder.decode(pending.subarray(0, pendingLength));
563
+ if (trailing) {
564
+ controller.enqueue(encoder.encode(stripToolPrefix(trailing)));
565
+ }
566
+ },
567
+ }));
568
+ const headers = headersAfterBodyTransform(response.headers);
309
569
  return new Response(stream, {
310
570
  status: response.status,
311
571
  statusText: response.statusText,
312
- headers: response.headers,
572
+ headers,
313
573
  });
314
574
  }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "1.8.2",
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"
7
8
  },
8
9
  "main": "./dist/index.js",
9
10
  "types": "./dist/index.d.ts",
11
+ "packageManager": "bun@1.3.14",
10
12
  "engines": {
11
13
  "bun": "1.3.14"
12
14
  },
@@ -19,26 +21,28 @@
19
21
  "dev": "bun scripts/dev.ts",
20
22
  "dev:clean": "bun scripts/dev-clean.ts",
21
23
  "extract": "bun scripts/extract-system-prompt.ts",
24
+ "check": "bun turbo check:all",
22
25
  "test": "bun test",
23
26
  "types": "tsc",
24
27
  "format": "biome check --write --unsafe",
25
28
  "format:check": "biome format .",
26
- "lint": "biome lint .",
29
+ "lint": "biome lint --error-on-warnings .",
27
30
  "change": "changeset",
28
- "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"
29
33
  },
30
- "peerDependencies": {
31
- "@opencode-ai/plugin": "*"
34
+ "dependencies": {
35
+ "@opencode-ai/plugin": "0.0.0-next-17444"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@biomejs/biome": "2.5.2",
35
39
  "@changesets/changelog-github": "^0.7.0",
36
40
  "@changesets/cli": "^2.31.0",
37
- "@opencode-ai/plugin": "1.17.13",
38
41
  "@tsconfig/bun": "1.0.10",
39
42
  "@types/bun": "1.3.14",
40
43
  "dedent": "^1.7.2",
41
44
  "lefthook": "2.1.9",
45
+ "turbo": "2.9.18",
42
46
  "typescript": "6.0.3"
43
47
  }
44
48
  }