@ex-machina/opencode-anthropic-auth 1.8.2 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,42 @@
1
1
  # OpenCode Anthropic Auth Plugin
2
2
 
3
+ [![OpenCode v1 — npm latest tag](https://img.shields.io/npm/v/%40ex-machina%2Fopencode-anthropic-auth/latest?label=OpenCode%20v1%20(latest))](https://www.npmjs.com/package/@ex-machina/opencode-anthropic-auth?activeTab=versions)
4
+ [![OpenCode v2 — npm next tag](https://img.shields.io/npm/v/%40ex-machina%2Fopencode-anthropic-auth/next?label=OpenCode%20v2%20(next))](https://www.npmjs.com/package/@ex-machina/opencode-anthropic-auth?activeTab=versions)
5
+
3
6
  > [!WARNING]
4
7
  > This plugin comes with no guarantees. You might be banned for breaking the TOS, you might not be. I don't work at Anthropic, nor am I an attorney.
5
8
  >
6
9
  > Use your best judgment and don't try to abuse the subscriptions. Plugins like oh-my-openagent are _known_ to trigger bans. Please be careful when using Ralph loops or insanely heavy usage patterns.
7
10
 
8
11
  > [!IMPORTANT]
9
- > If you are seeing issues, please try to `rm -rf ~/.cache/opencode/packages/@ex-machina` and check your `opencode.json` config to make sure you're on the latest version.
12
+ > If you are seeing issues, try `rm -rf ~/.cache/opencode/packages/@ex-machina` and confirm that your `opencode.json` uses the plugin release line for your OpenCode version.
10
13
  >
11
14
  > Try this FIRST before making an Issue. Thanks!
12
15
 
13
16
  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
17
 
18
+ > [!NOTE]
19
+ > This repository uses [Agent Facets](https://agentfacets.io) to manage agent capabilities. See the [quickstart](https://docs.agentfacets.io/quickstart) to install the CLI and get started.
20
+
21
+ ## Version support
22
+
23
+ | OpenCode version | Plugin release | Support branch | npm dist-tag | Configuration key |
24
+ |------------------|----------------|----------------|--------------|-------------------|
25
+ | OpenCode v1 | 1.x | [`main`](https://github.com/ex-machina-co/opencode-anthropic-auth/tree/main) | `latest` | `plugin` |
26
+ | OpenCode v2 | 2.x prereleases | [`v2/main`](https://github.com/ex-machina-co/opencode-anthropic-auth/tree/v2/main) | `next` | `plugins` |
27
+
28
+ Both release lines use the same npm package. They are not cross-compatible: the v1 plugin does not load in OpenCode v2, and the v2 plugin does not load in OpenCode v1. OpenCode v2's plugin API is still beta, so review the changelog before upgrading either side.
29
+
15
30
  ## Usage
16
31
 
17
- Add the plugin to your OpenCode configuration:
32
+ > [!TIP]
33
+ > Pin an exact plugin version for a stable setup that changes only when you choose to upgrade. If you intentionally want automatic updates, use the moving npm tag for your OpenCode release line.
34
+ >
35
+ > This applies to every OpenCode plugin: an unpinned or moving-tag dependency can install new code on startup, so only track automatic updates from publishers you trust.
36
+
37
+ ### OpenCode v1 (`latest`)
38
+
39
+ OpenCode v1 uses the singular `plugin` configuration key. A bare package spec tracks npm's `latest` tag:
18
40
 
19
41
  ```json
20
42
  {
@@ -22,47 +44,94 @@ Add the plugin to your OpenCode configuration:
22
44
  }
23
45
  ```
24
46
 
25
- > [!TIP]
26
- > It is STRONGLY advised that you pin the plugin to a version. This will keep you from getting automatic updates; however, this will protect you from nefarious updates.
27
- >
28
- > This holds true for ANY OpenCode plugin. If you do not pin them, OpenCode will automatically update them on startup. It's a massive vulnerability waiting to happen.
47
+ You can also write the moving tag explicitly as `@ex-machina/opencode-anthropic-auth@latest`.
48
+
49
+ For a stable setup, look up the exact version currently published on `latest`:
50
+
51
+ ```bash
52
+ npm view @ex-machina/opencode-anthropic-auth dist-tags.latest
53
+ ```
54
+
55
+ Substitute the command output for `<version>`:
56
+
57
+ ```json
58
+ {
59
+ "plugin": ["@ex-machina/opencode-anthropic-auth@<version>"]
60
+ }
61
+ ```
62
+
63
+ ### OpenCode v2 (`next`)
64
+
65
+ OpenCode v2 uses the plural `plugins` configuration key. Because npm's default tag points to the v1 line, specify `@next` if you want to track the newest v2 prerelease:
66
+
67
+ ```json
68
+ {
69
+ "plugins": ["@ex-machina/opencode-anthropic-auth@next"]
70
+ }
71
+ ```
29
72
 
30
- #### Example of pinned version
73
+ For a stable setup, look up the exact version currently published on `next`:
74
+
75
+ ```bash
76
+ npm view @ex-machina/opencode-anthropic-auth dist-tags.next
77
+ ```
78
+
79
+ Substitute the command output for `<version>`:
31
80
 
32
81
  ```json
33
82
  {
34
- "plugin": ["@ex-machina/opencode-anthropic-auth@1.8.1"]
83
+ "plugins": ["@ex-machina/opencode-anthropic-auth@<version>"]
35
84
  }
36
85
  ```
37
86
 
38
87
  ## Authentication Methods
39
88
 
40
- The plugin provides three authentication options:
89
+ ### OpenCode v1
90
+
91
+ OpenCode v1 provides three authentication options:
92
+
93
+ - **Claude Pro/Max** — OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
94
+ - Run `/connect`, select `Anthropic (API key)` -> `Claude Pro/Max`, and complete OAuth.
95
+ - **Create an API Key** — OAuth flow via `console.anthropic.com` that creates an API key on your behalf.
96
+ - **Manually enter API Key** — Standard API-key entry for users who already have one.
41
97
 
42
- - **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.
98
+ ### OpenCode v2
99
+
100
+ OpenCode v2 provides:
101
+
102
+ - **Claude Pro/Max** — OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
103
+ - Run `/connect`, select `Anthropic` -> `Claude Pro/Max`, and complete OAuth.
104
+ - **Manually enter API Key / `ANTHROPIC_API_KEY`** — Handled by OpenCode's built-in Anthropic integration, not by this plugin.
105
+
106
+ > [!NOTE]
107
+ > OpenCode v1 also offers a "Create an API Key" OAuth flow that mints and stores an API key. OpenCode v2's plugin API cannot yet complete an OAuth flow by storing a generated API key, so that option is unavailable in v2. 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.
46
108
 
47
109
  ## Configuration
48
110
 
49
- The plugin supports the following environment variables:
111
+ The plugin reads the following environment variables:
112
+
113
+ - **`ANTHROPIC_BASE_URL`** — Overrides the Anthropic API endpoint for both release lines, such as when using a proxy. Must be a valid HTTP(S) URL.
114
+ - **`ANTHROPIC_INSECURE`** — Skips TLS certificate verification. Behavior differs by OpenCode version:
115
+ - **OpenCode v1** — Set to `1` or `true` to skip verification. Only effective when `ANTHROPIC_BASE_URL` is also set.
116
+ - **OpenCode v2** — Not supported. OpenCode v2 plugin request hooks cannot disable TLS verification. If set, the plugin logs a warning and leaves verification enabled; requests to an untrusted or self-signed `ANTHROPIC_BASE_URL` will fail.
117
+ - **`ANTHROPIC_CLAUDE_CODE_VERSION`** — Overrides the Claude Code version reported to Anthropic for both release lines. Must be `major.minor.patch` (for example, `2.1.258`). Defaults to the bundled version; a malformed value is logged and the bundled version is used instead. A value older than the bundled version is honored but logs a warning, since reporting an older version can make newer models reject the request. Read once when the plugin loads, so restart OpenCode after changing it.
50
118
 
51
- | Variable | Description |
52
- |-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
53
- | `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. |
119
+ Anthropic gates model access on the reported Claude Code version server-side, returning a 400 `claude_code_version_too_old` error for models that require a newer client. `ANTHROPIC_CLAUDE_CODE_VERSION` lets you raise the reported version without waiting for a plugin release.
55
120
 
56
121
  ## How It Works
57
122
 
58
- For Claude Pro/Max authentication, the plugin:
123
+ For Claude Pro/Max authentication, both release lines:
124
+
125
+ 1. Initiate a PKCE OAuth flow against Anthropic's authorization endpoint
126
+ 2. Exchange the authorization code for access and refresh tokens
127
+ 3. Automatically refresh expired tokens
128
+ 4. Inject the required OAuth headers and beta flags into API requests
129
+ 5. Sanitize the system prompt for compatibility (see below)
59
130
 
60
- 1. Initiates a PKCE OAuth flow against Anthropic's authorization endpoint
61
- 2. Exchanges the authorization code for access and refresh tokens
62
- 3. Automatically refreshes expired tokens
63
- 4. Injects the required OAuth headers and beta flags into API requests
64
- 5. Sanitizes the system prompt for compatibility (see below)
65
- 6. Zeros out model costs (since usage is covered by the subscription)
131
+ Model-cost display differs by OpenCode version:
132
+
133
+ - **OpenCode v1** — The plugin zeros out displayed model costs because usage is covered by the subscription.
134
+ - **OpenCode v2** — OpenCode continues to display Anthropic's API prices 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.
66
135
 
67
136
  ### System Prompt Sanitization
68
137
 
@@ -90,7 +159,19 @@ This does three things:
90
159
  2. Symlinks the build output into `.opencode/plugins/` so OpenCode loads it as a local plugin
91
160
  3. Starts `tsc --watch` for automatic rebuilds on source changes
92
161
 
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.
162
+ After starting the dev script:
163
+
164
+ - On the `main` branch, restart OpenCode in this project directory.
165
+ - On the `v2/main` branch, restart OpenCode v2 (`opencode2`) in this project directory.
166
+
167
+ Edits to `src/` trigger a rebuild; restart the corresponding OpenCode version again to load the new build.
168
+
169
+ You can confirm that the v2 plugin loaded through the OpenCode v2 API:
170
+
171
+ ```bash
172
+ opencode2 api get /api/plugin # should list "ex-machina.anthropic-auth"
173
+ opencode2 api get /api/integration # anthropic should offer a "Claude Pro/Max" OAuth method
174
+ ```
94
175
 
95
176
  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
177
 
@@ -103,13 +184,18 @@ bun run dev:clean
103
184
 
104
185
  ### Publishing
105
186
 
106
- This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for more details.
187
+ This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for contributor details.
107
188
 
108
189
  ```bash
109
190
  bun change # create a changeset describing your changes
110
191
  ```
111
192
 
112
- When changesets are merged to `main`, CI will automatically open a release PR. Merging that PR publishes to npm.
193
+ Changesets merged to a release branch cause CI to open a release PR; merging that PR publishes to npm. The repository has two release trains:
194
+
195
+ - `main` publishes plugin v1 for OpenCode v1 to npm's `latest` tag.
196
+ - `v2/main` publishes plugin v2 prereleases for OpenCode v2 to npm's `next` tag.
197
+
198
+ Maintainers can find the complete two-train release and branch-sync process in the [release runbook](https://github.com/ex-machina-co/opencode-anthropic-auth/blob/v2/main/RELEASING.md).
113
199
 
114
200
  ## License
115
201
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Environment variable that overrides the reported Claude Code version.
3
+ *
4
+ * Anthropic gates model access on the reported version server-side, and that
5
+ * gate moves on Anthropic's schedule rather than this plugin's release
6
+ * schedule. The override lets users unblock a newly-gated model without
7
+ * waiting for a published bump.
8
+ */
9
+ export declare const ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR = "ANTHROPIC_CLAUDE_CODE_VERSION";
10
+ /**
11
+ * Outcome of reading the version override.
12
+ *
13
+ * The invalid arm carries no version: a malformed override must never reach
14
+ * the request path, so callers cannot accidentally report one. The outdated
15
+ * arm does carry one — an explicit older version is still honoured — but pairs
16
+ * it with the warning explaining why reporting it is risky.
17
+ */
18
+ export type ClaudeCodeVersionResolution = {
19
+ type: 'success';
20
+ version: string;
21
+ } | {
22
+ type: 'outdated';
23
+ version: string;
24
+ warning: string;
25
+ } | {
26
+ type: 'invalid';
27
+ error: string;
28
+ };
29
+ /**
30
+ * Resolve the Claude Code version to report to Anthropic.
31
+ *
32
+ * Returns the bundled version when the override is unset. A set override is
33
+ * trimmed and must look like a Claude Code release; anything else resolves to
34
+ * `invalid` with a message describing how to correct it. An override older
35
+ * than the bundled version resolves to `outdated`: it is still reported, since
36
+ * it was set deliberately, but it can lock the user out of newer models.
37
+ * Never throws.
38
+ */
39
+ export declare function resolveClaudeCodeVersion(raw?: string | undefined): ClaudeCodeVersionResolution;
package/dist/config.js ADDED
@@ -0,0 +1,72 @@
1
+ import { CLAUDE_CODE_VERSION } from "./constants.js";
2
+ /**
3
+ * Environment variable that overrides the reported Claude Code version.
4
+ *
5
+ * Anthropic gates model access on the reported version server-side, and that
6
+ * gate moves on Anthropic's schedule rather than this plugin's release
7
+ * schedule. The override lets users unblock a newly-gated model without
8
+ * waiting for a published bump.
9
+ */
10
+ export const ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR = 'ANTHROPIC_CLAUDE_CODE_VERSION';
11
+ /** Claude Code releases are `major.minor.patch` with numeric components. */
12
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
13
+ /**
14
+ * Is `candidate` an older Claude Code release than `baseline`?
15
+ *
16
+ * Both arguments must already match `VERSION_PATTERN`. Components are compared
17
+ * numerically rather than lexically — `2.1.99` sorts after `2.1.258` as a
18
+ * string but is the older release — and as `BigInt`, so an unbounded component
19
+ * cannot silently lose precision the way `Number` would.
20
+ */
21
+ function isOlderVersion(candidate, baseline) {
22
+ // The `0n` defaults are unreachable — `VERSION_PATTERN` guarantees exactly
23
+ // three components — but they keep the destructuring free of assertions.
24
+ const [major = 0n, minor = 0n, patch = 0n] = candidate
25
+ .split('.')
26
+ .map((part) => BigInt(part));
27
+ const [baseMajor = 0n, baseMinor = 0n, basePatch = 0n] = baseline
28
+ .split('.')
29
+ .map((part) => BigInt(part));
30
+ if (major !== baseMajor)
31
+ return major < baseMajor;
32
+ if (minor !== baseMinor)
33
+ return minor < baseMinor;
34
+ return patch < basePatch;
35
+ }
36
+ /**
37
+ * Resolve the Claude Code version to report to Anthropic.
38
+ *
39
+ * Returns the bundled version when the override is unset. A set override is
40
+ * trimmed and must look like a Claude Code release; anything else resolves to
41
+ * `invalid` with a message describing how to correct it. An override older
42
+ * than the bundled version resolves to `outdated`: it is still reported, since
43
+ * it was set deliberately, but it can lock the user out of newer models.
44
+ * Never throws.
45
+ */
46
+ export function resolveClaudeCodeVersion(raw = process.env[ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR]) {
47
+ if (raw === undefined) {
48
+ return { type: 'success', version: CLAUDE_CODE_VERSION };
49
+ }
50
+ const trimmed = raw.trim();
51
+ if (!VERSION_PATTERN.test(trimmed)) {
52
+ return {
53
+ type: 'invalid',
54
+ error: `${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} is set to ${JSON.stringify(raw)}, which is not a ` +
55
+ `Claude Code version. Expected major.minor.patch (e.g. ${CLAUDE_CODE_VERSION}). ` +
56
+ `Reporting the bundled version ${CLAUDE_CODE_VERSION} instead — correct or unset ` +
57
+ `${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} and restart OpenCode to use the override.`,
58
+ };
59
+ }
60
+ if (isOlderVersion(trimmed, CLAUDE_CODE_VERSION)) {
61
+ return {
62
+ type: 'outdated',
63
+ version: trimmed,
64
+ warning: `${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} is set to ${JSON.stringify(trimmed)}, which is older ` +
65
+ `than the bundled Claude Code version ${CLAUDE_CODE_VERSION}. Anthropic gates model access on ` +
66
+ `the reported version, so reporting an older one can make newer models reject the request. ` +
67
+ `Set ${ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR} to ${CLAUDE_CODE_VERSION} or newer — or unset it ` +
68
+ `to use the bundled version — and restart OpenCode.`,
69
+ };
70
+ }
71
+ return { type: 'success', version: trimmed };
72
+ }
@@ -24,7 +24,15 @@ export declare const CCH_POSITIONS: number[];
24
24
  */
25
25
  export declare const CLAUDE_CODE_VERSION = "2.1.258";
26
26
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
27
- export declare const USER_AGENT = "claude-cli/2.1.258 (external, cli)";
27
+ /**
28
+ * Build the `user-agent` value for a reported Claude Code version.
29
+ *
30
+ * Both places we report the version (this header and the billing header's
31
+ * `cc_version`) must agree, so callers pass the same resolved version to each
32
+ * rather than reading a second, independently-derived constant.
33
+ */
34
+ export declare function formatUserAgent(version: string): string;
35
+ export declare const USER_AGENT: string;
28
36
  /**
29
37
  * Anchors that identify paragraphs to remove from the system prompt.
30
38
  * Any paragraph (text between blank lines) containing one of these
package/dist/constants.js CHANGED
@@ -34,7 +34,17 @@ export const CCH_POSITIONS = [4, 7, 20];
34
34
  */
35
35
  export const CLAUDE_CODE_VERSION = '2.1.258';
36
36
  export const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
37
- export const USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`;
37
+ /**
38
+ * Build the `user-agent` value for a reported Claude Code version.
39
+ *
40
+ * Both places we report the version (this header and the billing header's
41
+ * `cc_version`) must agree, so callers pass the same resolved version to each
42
+ * rather than reading a second, independently-derived constant.
43
+ */
44
+ export function formatUserAgent(version) {
45
+ return `claude-cli/${version} (external, cli)`;
46
+ }
47
+ export const USER_AGENT = formatUserAgent(CLAUDE_CODE_VERSION);
38
48
  /**
39
49
  * Anchors that identify paragraphs to remove from the system prompt.
40
50
  * Any paragraph (text between blank lines) containing one of these
package/dist/index.js CHANGED
@@ -1,7 +1,42 @@
1
1
  import { authorize, exchange } from "./auth.js";
2
- import { CLIENT_ID, TOKEN_URL } from "./constants.js";
2
+ import { resolveClaudeCodeVersion } from "./config.js";
3
+ import { CLAUDE_CODE_VERSION, CLIENT_ID, TOKEN_URL } from "./constants.js";
3
4
  import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
5
+ /**
6
+ * Report a problem with the version override to the server log.
7
+ *
8
+ * Best-effort: a misconfigured override either degrades to the bundled version
9
+ * or is honoured as set, so a logging failure must not take the plugin down
10
+ * with it.
11
+ */
12
+ async function logVersionOverrideIssue(client, level, message) {
13
+ try {
14
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose app.log
15
+ await client?.app?.log({
16
+ body: {
17
+ service: 'anthropic-auth',
18
+ level,
19
+ message,
20
+ },
21
+ });
22
+ }
23
+ catch {
24
+ /* Logging is best-effort; the resolved version still applies. */
25
+ }
26
+ }
4
27
  export const AnthropicAuthPlugin = async ({ client }) => {
28
+ // Resolved once per plugin instance so every request reports the same
29
+ // version in both the user-agent and the billing header.
30
+ const resolution = resolveClaudeCodeVersion();
31
+ if (resolution.type === 'invalid') {
32
+ await logVersionOverrideIssue(client, 'error', resolution.error);
33
+ }
34
+ else if (resolution.type === 'outdated') {
35
+ await logVersionOverrideIssue(client, 'warn', resolution.warning);
36
+ }
37
+ // Only a malformed override lacks a usable version; an outdated one was set
38
+ // deliberately, so it is reported as configured.
39
+ const claudeCodeVersion = resolution.type === 'invalid' ? CLAUDE_CODE_VERSION : resolution.version;
5
40
  return {
6
41
  auth: {
7
42
  provider: 'anthropic',
@@ -104,10 +139,10 @@ export const AnthropicAuthPlugin = async ({ client }) => {
104
139
  }
105
140
  const requestHeaders = mergeHeaders(input, init);
106
141
  // biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
107
- setOAuthHeaders(requestHeaders, auth.access);
142
+ setOAuthHeaders(requestHeaders, auth.access, claudeCodeVersion);
108
143
  let body = init?.body;
109
144
  if (body && typeof body === 'string') {
110
- body = rewriteRequestBody(body);
145
+ body = rewriteRequestBody(body, claudeCodeVersion);
111
146
  }
112
147
  const rewritten = rewriteUrl(input);
113
148
  const response = await fetch(rewritten.input, {
@@ -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
@@ -11,8 +13,11 @@ export declare function mergeBetaHeaders(headers: Headers): string;
11
13
  /**
12
14
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
13
15
  * Removes x-api-key since we're using OAuth.
16
+ *
17
+ * `version` must be the same value passed to rewriteRequestBody for the same
18
+ * request, otherwise the two reported versions disagree.
14
19
  */
15
- export declare function setOAuthHeaders(headers: Headers, accessToken: string): Headers;
20
+ export declare function setOAuthHeaders(headers: Headers, accessToken: string, version?: string): Headers;
16
21
  /**
17
22
  * Add TOOL_PREFIX to tool names in the request body.
18
23
  * Prefixes both tool definitions and tool_use blocks in messages.
@@ -64,8 +69,11 @@ type SystemBlock = {
64
69
  export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[];
65
70
  /**
66
71
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
72
+ *
73
+ * `version` must be the same value passed to setOAuthHeaders for the same
74
+ * request, otherwise the two reported versions disagree.
67
75
  */
68
- export declare function rewriteRequestBody(body: string): string;
76
+ export declare function rewriteRequestBody(body: string, version?: string): string;
69
77
  /**
70
78
  * Create a streaming response that strips the tool prefix from tool names.
71
79
  */
package/dist/transform.js CHANGED
@@ -1,5 +1,208 @@
1
1
  import { buildBillingHeaderValue } from "./cch.js";
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";
2
+ import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_CODE_VERSION, formatUserAgent, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, } 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);
@@ -68,11 +271,14 @@ export function mergeBetaHeaders(headers) {
68
271
  /**
69
272
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
70
273
  * Removes x-api-key since we're using OAuth.
274
+ *
275
+ * `version` must be the same value passed to rewriteRequestBody for the same
276
+ * request, otherwise the two reported versions disagree.
71
277
  */
72
- export function setOAuthHeaders(headers, accessToken) {
278
+ export function setOAuthHeaders(headers, accessToken, version = CLAUDE_CODE_VERSION) {
73
279
  headers.set('authorization', `Bearer ${accessToken}`);
74
280
  headers.set('anthropic-beta', mergeBetaHeaders(headers));
75
- headers.set('user-agent', USER_AGENT);
281
+ headers.set('user-agent', formatUserAgent(version));
76
282
  headers.delete('x-api-key');
77
283
  return headers;
78
284
  }
@@ -264,13 +470,16 @@ export function prependClaudeCodeIdentity(system) {
264
470
  }
265
471
  /**
266
472
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
473
+ *
474
+ * `version` must be the same value passed to setOAuthHeaders for the same
475
+ * request, otherwise the two reported versions disagree.
267
476
  */
268
- export function rewriteRequestBody(body) {
477
+ export function rewriteRequestBody(body, version = CLAUDE_CODE_VERSION) {
269
478
  try {
270
479
  const parsed = JSON.parse(body);
271
480
  const billingHeader = Array.isArray(parsed.messages) &&
272
481
  parsed.messages.some((message) => message.role === 'user')
273
- ? buildBillingHeaderValue(parsed.messages, undefined, CLAUDE_CODE_ENTRYPOINT)
482
+ ? buildBillingHeaderValue(parsed.messages, version, CLAUDE_CODE_ENTRYPOINT)
274
483
  : null;
275
484
  // Sanitize system prompt and prepend Claude Code identity
276
485
  parsed.system = prependClaudeCodeIdentity(parsed.system);
@@ -289,26 +498,83 @@ export function rewriteRequestBody(body) {
289
498
  * Create a streaming response that strips the tool prefix from tool names.
290
499
  */
291
500
  export function createStrippedStream(response) {
501
+ const mediaType = response.headers
502
+ .get('content-type')
503
+ ?.split(';', 1)[0]
504
+ ?.trim()
505
+ .toLowerCase();
292
506
  if (!response.body)
293
507
  return response;
294
- const reader = response.body.getReader();
508
+ if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
509
+ const stream = createJsonToolNameStream(response.body);
510
+ const headers = headersAfterBodyTransform(response.headers);
511
+ return new Response(stream, {
512
+ status: response.status,
513
+ statusText: response.statusText,
514
+ headers,
515
+ });
516
+ }
517
+ if (mediaType !== 'text/event-stream')
518
+ return response;
295
519
  const decoder = new TextDecoder();
296
520
  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();
521
+ let pending = new Uint8Array(0);
522
+ let pendingLength = 0;
523
+ const appendPending = (bytes) => {
524
+ const requiredLength = pendingLength + bytes.byteLength;
525
+ if (requiredLength > MAX_SSE_LINE_BYTES) {
526
+ throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
527
+ }
528
+ if (requiredLength > pending.byteLength) {
529
+ let capacity = Math.max(1024, pending.byteLength);
530
+ while (capacity < requiredLength) {
531
+ capacity = Math.min(MAX_SSE_LINE_BYTES, capacity * 2);
532
+ }
533
+ const expanded = new Uint8Array(capacity);
534
+ expanded.set(pending.subarray(0, pendingLength));
535
+ pending = expanded;
536
+ }
537
+ pending.set(bytes, pendingLength);
538
+ pendingLength = requiredLength;
539
+ };
540
+ const stream = response.body.pipeThrough(new TransformStream({
541
+ transform(chunk, controller) {
542
+ let lastLineBreak = -1;
543
+ let lineLength = pendingLength;
544
+ for (let index = 0; index < chunk.byteLength; index++) {
545
+ if (chunk[index] === 0x0a || chunk[index] === 0x0d) {
546
+ lastLineBreak = index;
547
+ lineLength = 0;
548
+ }
549
+ else {
550
+ lineLength++;
551
+ if (lineLength > MAX_SSE_LINE_BYTES) {
552
+ throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
553
+ }
554
+ }
555
+ }
556
+ if (lastLineBreak < 0) {
557
+ appendPending(chunk);
302
558
  return;
303
559
  }
304
- let text = decoder.decode(value, { stream: true });
305
- text = stripToolPrefix(text);
306
- controller.enqueue(encoder.encode(text));
560
+ const completeLines = decoder.decode(pending.subarray(0, pendingLength), {
561
+ stream: true,
562
+ }) + decoder.decode(chunk.subarray(0, lastLineBreak + 1));
563
+ pendingLength = 0;
564
+ appendPending(chunk.subarray(lastLineBreak + 1));
565
+ controller.enqueue(encoder.encode(stripToolPrefix(completeLines)));
307
566
  },
308
- });
567
+ flush(controller) {
568
+ const trailing = decoder.decode(pending.subarray(0, pendingLength));
569
+ if (trailing) {
570
+ controller.enqueue(encoder.encode(stripToolPrefix(trailing)));
571
+ }
572
+ },
573
+ }));
574
+ const headers = headersAfterBodyTransform(response.headers);
309
575
  return new Response(stream, {
310
576
  status: response.status,
311
577
  statusText: response.statusText,
312
- headers: response.headers,
578
+ headers,
313
579
  });
314
580
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ex-machina-co/opencode-anthropic-auth"
7
7
  },
8
8
  "main": "./dist/index.js",
9
9
  "types": "./dist/index.d.ts",
10
+ "packageManager": "bun@1.3.14",
10
11
  "engines": {
11
12
  "bun": "1.3.14"
12
13
  },
@@ -19,11 +20,12 @@
19
20
  "dev": "bun scripts/dev.ts",
20
21
  "dev:clean": "bun scripts/dev-clean.ts",
21
22
  "extract": "bun scripts/extract-system-prompt.ts",
23
+ "check": "bun turbo check:all",
22
24
  "test": "bun test",
23
25
  "types": "tsc",
24
26
  "format": "biome check --write --unsafe",
25
27
  "format:check": "biome format .",
26
- "lint": "biome lint .",
28
+ "lint": "biome lint --error-on-warnings .",
27
29
  "change": "changeset",
28
30
  "release": "bun run build && bun change publish"
29
31
  },
@@ -39,6 +41,7 @@
39
41
  "@types/bun": "1.3.14",
40
42
  "dedent": "^1.7.2",
41
43
  "lefthook": "2.1.9",
44
+ "turbo": "2.9.18",
42
45
  "typescript": "6.0.3"
43
46
  }
44
47
  }