opencode-claude-max-proxy 1.10.0 → 1.11.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 +96 -128
- package/package.json +4 -2
- package/src/mcpTools.ts +185 -0
- package/src/plugin/claude-max-headers.ts +52 -0
- package/src/proxy/server.ts +114 -27
- package/src/proxy/sessionStore.ts +3 -1
package/README.md
CHANGED
|
@@ -4,61 +4,64 @@
|
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://github.com/rynfar/opencode-claude-max-proxy/stargazers)
|
|
6
6
|
|
|
7
|
-
A transparent proxy that
|
|
7
|
+
A transparent proxy that allows a Claude Max subscription to be used with [OpenCode](https://opencode.ai), preserving multi-model agent routing.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
OpenCode targets the Anthropic API, while Claude Max provides access to Claude via the [Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). The proxy forwards tool calls from Claude Max to OpenCode so agent routing works correctly.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Tool execution is handled within the Agent SDK, which is responsible for running tools, coordinating sub-agents, and streaming responses. Because the SDK only has access to Claude models, any tool calls or delegated tasks are executed within that scope. In configurations such as [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode), where agents may be assigned to OpenaI, Gemini, or other providers, those assignments are not preserved at execution time, and all work is effectively routed through Claude.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
We avoid that limitation by forwarding tool calls instead of executing them. When a `tool_use` event is emitted, the proxy intercepts it, halts the current turn, and sends the raw payload back to OpenCode. OpenCode executes the tool, including any agent routing, and returns the result as a `tool_result`. The proxy then resumes the SDK session.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
The effect: Claude thinks it's calling tools normally. OpenCode thinks it's talking to the Anthropic API. But behind the scenes, your oracle agent runs on GPT-5.4, your explore agent runs on Gemini, and the main session runs on Claude Max — exactly as configured.
|
|
15
|
+
From Claude’s perspective, tool usage proceeds normally. From OpenCode’s perspective, it is interacting with the Anthropic API. Execution remains distributed according to the configured agents, allowing different models to handle different roles without being constrained by the SDK.
|
|
18
16
|
|
|
19
17
|
```
|
|
20
18
|
┌──────────┐ ┌───────────────┐ ┌──────────────┐
|
|
21
|
-
│ │
|
|
19
|
+
│ │ │ │ │ │
|
|
22
20
|
│ OpenCode │ ─────────────► │ Proxy │ ───────────► │ Claude Max │
|
|
23
|
-
│ │ │ (localhost) │ │
|
|
24
|
-
│ │
|
|
25
|
-
│ │
|
|
26
|
-
│ │ │
|
|
27
|
-
│ │
|
|
28
|
-
│ │
|
|
29
|
-
│ │
|
|
30
|
-
│ │
|
|
31
|
-
│ │
|
|
32
|
-
│ │ │
|
|
33
|
-
│ │
|
|
34
|
-
│ │
|
|
35
|
-
│ │
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
21
|
+
│ │ │ (localhost) │ │ (Agent SDK) │
|
|
22
|
+
│ │ │ │ │ │
|
|
23
|
+
│ │ │ │ ◄─────────── │ tool_use │
|
|
24
|
+
│ │ │ ◄──────────── │ │ │
|
|
25
|
+
│ │ │ Intercept │ └──────────────┘
|
|
26
|
+
│ │ │ (stop turn) │
|
|
27
|
+
│ │ │ │
|
|
28
|
+
│ │ │ ▼
|
|
29
|
+
│ │ │ ┌──────────────┐
|
|
30
|
+
│ │ │ │ OpenCode │
|
|
31
|
+
│ │ │ │ agent │
|
|
32
|
+
│ │ │ │ system │
|
|
33
|
+
│ │ │ └──────────────┘
|
|
34
|
+
│ │ │ │
|
|
35
|
+
│ │ │ ▼
|
|
36
|
+
│ │ │ ◄────────────
|
|
37
|
+
│ │ │ Resume turn
|
|
38
|
+
│ │ │
|
|
39
|
+
│ │ ◄───────────── │
|
|
40
|
+
│ │ response │
|
|
41
|
+
└──────────┘ └───────────────┘
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
## How Passthrough Works
|
|
44
45
|
|
|
45
|
-
The
|
|
46
|
+
The Claude Agent SDK exposes a `PreToolUse` hook that fires before any tool executes. Combined with `maxTurns: 1`, it gives us precise control over the execution boundary without monkey-patching or stream rewriting.
|
|
46
47
|
|
|
47
48
|
1. **Claude generates a response** with `tool_use` blocks (Read a file, delegate to an agent, run a command)
|
|
48
|
-
2. **The PreToolUse hook fires** for each tool call
|
|
49
|
+
2. **The PreToolUse hook fires** for each tool call - we capture the tool name, input, and ID, then return `decision: "block"` to prevent SDK-internal execution
|
|
49
50
|
3. **The SDK stops** (blocked tool + maxTurns:1 = turn complete) and we have the full tool_use payload
|
|
50
51
|
4. **The proxy returns it to OpenCode** as a standard Anthropic API response with `stop_reason: "tool_use"`
|
|
51
52
|
5. **OpenCode handles everything** — file reads, shell commands, and crucially, `Task` delegation through its own agent system with full model routing
|
|
52
53
|
6. **OpenCode sends `tool_result` back**, the proxy resumes the SDK session, and Claude continues
|
|
53
54
|
|
|
54
|
-
No monkey-patching. No forked SDKs. No fragile stream rewriting. Just a hook and a turn limit.
|
|
55
|
-
|
|
56
55
|
## Quick Start
|
|
57
56
|
|
|
58
57
|
### Prerequisites
|
|
59
58
|
|
|
60
|
-
1. **Claude Max subscription** — [Subscribe here](https://claude.ai/settings/
|
|
59
|
+
1. **Claude Max subscription** — [Subscribe here](https://claude.ai/settings/billing)
|
|
61
60
|
2. **Claude CLI** authenticated: `npm install -g @anthropic-ai/claude-code && claude login`
|
|
61
|
+
3. **Clear any existing OpenCode Anthropic auth** — if you previously used OpenCode with a real Anthropic API key, log out first or the cached auth will override the proxy:
|
|
62
|
+
```bash
|
|
63
|
+
opencode auth logout # select "anthropic" when prompted
|
|
64
|
+
```
|
|
62
65
|
|
|
63
66
|
### Option A: npm Install
|
|
64
67
|
|
|
@@ -98,7 +101,7 @@ docker compose exec proxy claude login
|
|
|
98
101
|
curl http://127.0.0.1:3456/health
|
|
99
102
|
```
|
|
100
103
|
|
|
101
|
-
> **Note:** On macOS,
|
|
104
|
+
> **Note:** On macOS, use `./bin/docker-auth.sh` to copy host credentials into the container (handles the keychain/scopes format difference). On Linux, volume-mounting `~/.claude` may work directly.
|
|
102
105
|
|
|
103
106
|
### Connect OpenCode
|
|
104
107
|
|
|
@@ -108,9 +111,9 @@ curl http://127.0.0.1:3456/health
|
|
|
108
111
|
./bin/oc.sh
|
|
109
112
|
```
|
|
110
113
|
|
|
111
|
-
Each terminal gets its own proxy on a random port.
|
|
114
|
+
Each terminal gets its own proxy on a random port. Proxy starts automatically, connects OpenCode, and cleans up on exit. Sessions resume across terminals via a shared file store.
|
|
112
115
|
|
|
113
|
-
|
|
116
|
+
Shell alias:
|
|
114
117
|
|
|
115
118
|
```bash
|
|
116
119
|
# ~/.zshrc or ~/.bashrc
|
|
@@ -119,7 +122,7 @@ alias oc='/path/to/opencode-claude-max-proxy/bin/oc.sh'
|
|
|
119
122
|
|
|
120
123
|
#### Shared Proxy
|
|
121
124
|
|
|
122
|
-
|
|
125
|
+
For a single long-running proxy:
|
|
123
126
|
|
|
124
127
|
```bash
|
|
125
128
|
# Terminal 1: start the proxy
|
|
@@ -129,11 +132,11 @@ CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy
|
|
|
129
132
|
ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
|
|
130
133
|
```
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
`ANTHROPIC_API_KEY` can be any non-empty string. Authentication is handled by `claude login`.
|
|
133
136
|
|
|
134
137
|
#### OpenCode Desktop / Config File
|
|
135
138
|
|
|
136
|
-
|
|
139
|
+
Set the proxy URL in `~/.config/opencode/opencode.json` to use with Desktop or avoid env vars.
|
|
137
140
|
|
|
138
141
|
```json
|
|
139
142
|
{
|
|
@@ -148,7 +151,7 @@ For OpenCode Desktop (or to avoid env vars), add the proxy to `~/.config/opencod
|
|
|
148
151
|
}
|
|
149
152
|
```
|
|
150
153
|
|
|
151
|
-
>
|
|
154
|
+
> Desktop requires the shared proxy on a fixed port. Run `CLAUDE_PROXY_PASSTHROUGH=1 bun run proxy` in the background. Sessions are shared between Desktop and terminal instances.
|
|
152
155
|
|
|
153
156
|
## Modes
|
|
154
157
|
|
|
@@ -172,13 +175,13 @@ bun run proxy
|
|
|
172
175
|
|
|
173
176
|
Tools execute inside the proxy via MCP. Subagents run on Claude via the SDK's native agent system. Simpler setup, but all agents use Claude regardless of oh-my-opencode config.
|
|
174
177
|
|
|
175
|
-
|
|
|
176
|
-
|
|
177
|
-
| Tool execution
|
|
178
|
-
| Agent delegation
|
|
179
|
-
| oh-my-opencode models | ✅ Respected
|
|
180
|
-
| Agent system prompts
|
|
181
|
-
| Setup complexity
|
|
178
|
+
| | Passthrough | Internal |
|
|
179
|
+
| --------------------- | ---------------------- | ------------------- |
|
|
180
|
+
| Tool execution | OpenCode | Proxy (MCP) |
|
|
181
|
+
| Agent delegation | OpenCode → multi-model | SDK → Claude only |
|
|
182
|
+
| oh-my-opencode models | ✅ Respected | ❌ All Claude |
|
|
183
|
+
| Agent system prompts | ✅ Full | ⚠️ Description only |
|
|
184
|
+
| Setup complexity | Same | Same |
|
|
182
185
|
|
|
183
186
|
## Works With Any Agent Framework
|
|
184
187
|
|
|
@@ -192,112 +195,93 @@ In internal mode, a `PreToolUse` hook fuzzy-matches agent names as a safety net
|
|
|
192
195
|
|
|
193
196
|
## Session Resume
|
|
194
197
|
|
|
195
|
-
The proxy tracks SDK session IDs and resumes conversations on follow-up requests
|
|
198
|
+
The proxy tracks SDK session IDs and resumes conversations on follow-up requests. Sessions are stored in `~/.cache/opencode-claude-max-proxy/sessions.json`, shared across all proxy instances (including per-terminal proxies).
|
|
196
199
|
|
|
197
|
-
|
|
198
|
-
- **Better context** — the SDK remembers tool results from previous turns
|
|
199
|
-
- **Works across terminals** — sessions are shared via a file store at `~/.cache/opencode-claude-max-proxy/sessions.json`
|
|
200
|
+
Lookup order:
|
|
200
201
|
|
|
201
|
-
|
|
202
|
+
1. **Header-based** — add the included OpenCode plugin to inject session headers:
|
|
202
203
|
|
|
203
|
-
1. **Header-based** (recommended) — Add the included OpenCode plugin:
|
|
204
204
|
```json
|
|
205
|
-
{
|
|
205
|
+
{
|
|
206
|
+
"plugin": [
|
|
207
|
+
"./path/to/opencode-claude-max-proxy/src/plugin/claude-max-headers.ts"
|
|
208
|
+
]
|
|
209
|
+
}
|
|
206
210
|
```
|
|
207
211
|
|
|
208
|
-
2. **Fingerprint-based** (automatic fallback) — hashes the first user message to
|
|
212
|
+
2. **Fingerprint-based** (automatic fallback) — hashes the first user message to match returning conversations
|
|
209
213
|
|
|
210
|
-
Sessions
|
|
214
|
+
Sessions expire after 24 hours.
|
|
211
215
|
|
|
212
216
|
## Configuration
|
|
213
217
|
|
|
214
|
-
| Variable
|
|
215
|
-
|
|
216
|
-
| `CLAUDE_PROXY_PASSTHROUGH`
|
|
217
|
-
| `CLAUDE_PROXY_PORT`
|
|
218
|
-
| `CLAUDE_PROXY_HOST`
|
|
219
|
-
| `CLAUDE_PROXY_WORKDIR`
|
|
220
|
-
| `CLAUDE_PROXY_MAX_CONCURRENT`
|
|
221
|
-
| `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120
|
|
218
|
+
| Variable | Default | Description |
|
|
219
|
+
| ----------------------------------- | --------- | -------------------------------------------------------- |
|
|
220
|
+
| `CLAUDE_PROXY_PASSTHROUGH` | (unset) | Enable passthrough mode to forward all tools to OpenCode |
|
|
221
|
+
| `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
|
|
222
|
+
| `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
|
|
223
|
+
| `CLAUDE_PROXY_WORKDIR` | (cwd) | Working directory for Claude and tools |
|
|
224
|
+
| `CLAUDE_PROXY_MAX_CONCURRENT` | 1 | Max concurrent SDK sessions (increase with caution) |
|
|
225
|
+
| `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | 120 | Connection idle timeout |
|
|
222
226
|
|
|
223
227
|
## Concurrency
|
|
224
228
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
**Shared proxy** supports concurrent requests but has a known limitation:
|
|
228
|
-
|
|
229
|
-
> **⚠️ Known Issue: Bun SSE Crash ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947))**
|
|
230
|
-
>
|
|
231
|
-
> The Claude Agent SDK's `cli.js` subprocess (compiled with Bun) has a known segfault during cleanup of concurrent streaming responses.
|
|
232
|
-
>
|
|
233
|
-
> - **All responses are delivered correctly** — the crash only occurs after responses complete
|
|
234
|
-
> - **The supervisor auto-restarts** in ~1-3 seconds
|
|
235
|
-
> - **Per-terminal proxies avoid this entirely** — no concurrency, no crash
|
|
236
|
-
>
|
|
237
|
-
> We are monitoring the upstream Bun issue for a fix.
|
|
238
|
-
|
|
239
|
-
## Model Mapping
|
|
240
|
-
|
|
241
|
-
| OpenCode Model | Claude SDK |
|
|
242
|
-
|----------------|------------|
|
|
243
|
-
| `anthropic/claude-opus-*` | opus |
|
|
244
|
-
| `anthropic/claude-sonnet-*` | sonnet (default) |
|
|
245
|
-
| `anthropic/claude-haiku-*` | haiku |
|
|
229
|
+
Per-terminal proxies (`oc.sh`) avoid concurrency issues entirely. Each terminal gets its own proxy.
|
|
246
230
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
This project is an **unofficial wrapper** around Anthropic's publicly available [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It is not affiliated with, endorsed by, or supported by Anthropic.
|
|
250
|
-
|
|
251
|
-
**Use at your own risk.** The authors make no claims regarding compliance with Anthropic's Terms of Service. It is your responsibility to review and comply with [Anthropic's Terms of Service](https://www.anthropic.com/terms) and any applicable usage policies. Terms may change at any time.
|
|
252
|
-
|
|
253
|
-
This project calls `query()` from Anthropic's public npm package using your own authenticated account. No API keys are intercepted, no authentication is bypassed, and no proprietary systems are reverse-engineered.
|
|
231
|
+
The shared proxy supports concurrent requests but the SDK's `cli.js` subprocess (compiled with Bun) can segfault during stream cleanup ([oven-sh/bun#17947](https://github.com/oven-sh/bun/issues/17947)). Responses are always delivered correctly; the crash occurs after completion. The supervisor auto-restarts within a few seconds.
|
|
254
232
|
|
|
255
233
|
## FAQ
|
|
256
234
|
|
|
257
235
|
<details>
|
|
258
236
|
<summary><strong>Why passthrough mode instead of handling tools internally?</strong></summary>
|
|
259
237
|
|
|
260
|
-
|
|
238
|
+
If the Agent SDK executes tools directly, everything runs through Claude. Any agent routing defined in OpenCode is bypassed. Passthrough mode just sends the tool calls to OpenCode to run.
|
|
239
|
+
|
|
261
240
|
</details>
|
|
262
241
|
|
|
263
242
|
<details>
|
|
264
243
|
<summary><strong>Does this work without oh-my-opencode?</strong></summary>
|
|
265
244
|
|
|
266
|
-
Yes. Both modes work with native OpenCode (build + plan agents) and with any custom agents defined in your `opencode.json`. oh-my-opencode just adds more agents and model routing
|
|
245
|
+
Yes. Both modes work with native OpenCode (build + plan agents) and with any custom agents defined in your `opencode.json`. oh-my-opencode just adds more agents and model routing. The proxy handles whatever OpenCode sends.
|
|
246
|
+
|
|
267
247
|
</details>
|
|
268
248
|
|
|
269
249
|
<details>
|
|
270
|
-
<summary><strong>Why do I need ANTHROPIC_API_KEY=dummy
|
|
250
|
+
<summary><strong>Why do I need `ANTHROPIC_API_KEY=dummy`?</strong></summary>
|
|
271
251
|
|
|
272
252
|
OpenCode requires an API key to be set, but the proxy never uses it. Authentication is handled by your `claude login` session through the Agent SDK.
|
|
253
|
+
|
|
273
254
|
</details>
|
|
274
255
|
|
|
275
256
|
<details>
|
|
276
257
|
<summary><strong>What about rate limits?</strong></summary>
|
|
277
258
|
|
|
278
259
|
Your Claude Max subscription has its own usage limits. The proxy doesn't add any additional limits. Concurrent requests are supported.
|
|
260
|
+
|
|
279
261
|
</details>
|
|
280
262
|
|
|
281
263
|
<details>
|
|
282
264
|
<summary><strong>Is my data sent anywhere else?</strong></summary>
|
|
283
265
|
|
|
284
266
|
No. The proxy runs locally. Requests go directly to Claude through the official SDK. In passthrough mode, tool execution happens in OpenCode on your machine.
|
|
267
|
+
|
|
285
268
|
</details>
|
|
286
269
|
|
|
287
270
|
<details>
|
|
288
271
|
<summary><strong>Why does internal mode use MCP tools?</strong></summary>
|
|
289
272
|
|
|
290
273
|
The Claude Agent SDK uses different parameter names for tools than OpenCode (e.g., `file_path` vs `filePath`). Internal mode provides its own MCP tools with SDK-compatible parameter names. Passthrough mode doesn't need this since OpenCode handles tool execution directly.
|
|
274
|
+
|
|
291
275
|
</details>
|
|
292
276
|
|
|
293
277
|
## Troubleshooting
|
|
294
278
|
|
|
295
|
-
| Problem
|
|
296
|
-
|
|
297
|
-
| "Authentication failed"
|
|
298
|
-
| "Connection refused"
|
|
299
|
-
| "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567`
|
|
300
|
-
| Title generation fails
|
|
279
|
+
| Problem | Solution |
|
|
280
|
+
| ----------------------------- | ------------------------------------------------------------------------- |
|
|
281
|
+
| "Authentication failed" | Run `claude login` to authenticate |
|
|
282
|
+
| "Connection refused" | Make sure the proxy is running: `bun run proxy` |
|
|
283
|
+
| "Port 3456 is already in use" | `kill $(lsof -ti :3456)` or use `CLAUDE_PROXY_PORT=4567` |
|
|
284
|
+
| Title generation fails | Set `"small_model": "anthropic/claude-haiku-4-5"` in your OpenCode config |
|
|
301
285
|
|
|
302
286
|
## Auto-start (macOS)
|
|
303
287
|
|
|
@@ -331,23 +315,14 @@ EOF
|
|
|
331
315
|
launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
|
|
332
316
|
```
|
|
333
317
|
|
|
334
|
-
##
|
|
318
|
+
## Development
|
|
319
|
+
|
|
320
|
+
### Run tests
|
|
335
321
|
|
|
336
322
|
```bash
|
|
337
323
|
bun test
|
|
338
324
|
```
|
|
339
325
|
|
|
340
|
-
106 tests across 13 files covering:
|
|
341
|
-
- Passthrough tool forwarding and tool_result acceptance
|
|
342
|
-
- PreToolUse hook interception and agent name fuzzy matching
|
|
343
|
-
- SDK agent definition extraction (native OpenCode + oh-my-opencode)
|
|
344
|
-
- MCP tool filtering (internal mode)
|
|
345
|
-
- Session resume (header-based and fingerprint-based)
|
|
346
|
-
- Streaming message deduplication
|
|
347
|
-
- Working directory propagation
|
|
348
|
-
- Concurrent request handling
|
|
349
|
-
- Error classification (auth, rate limit, billing, timeout)
|
|
350
|
-
|
|
351
326
|
### Health Endpoint
|
|
352
327
|
|
|
353
328
|
```bash
|
|
@@ -356,7 +331,7 @@ curl http://127.0.0.1:3456/health
|
|
|
356
331
|
|
|
357
332
|
Returns auth status, subscription type, and proxy mode. Use this to verify the proxy is running and authenticated before connecting OpenCode.
|
|
358
333
|
|
|
359
|
-
|
|
334
|
+
### Architecture
|
|
360
335
|
|
|
361
336
|
```
|
|
362
337
|
src/
|
|
@@ -368,24 +343,17 @@ src/
|
|
|
368
343
|
├── mcpTools.ts # MCP tool definitions for internal mode (read, write, edit, bash, glob, grep)
|
|
369
344
|
├── logger.ts # Structured logging with AsyncLocalStorage context
|
|
370
345
|
├── plugin/
|
|
371
|
-
|
|
372
|
-
└── __tests__/ # 106 tests across 13 files
|
|
373
|
-
├── helpers.ts
|
|
374
|
-
├── integration.test.ts
|
|
375
|
-
├── proxy-agent-definitions.test.ts
|
|
376
|
-
├── proxy-agent-fuzzy-match.test.ts
|
|
377
|
-
├── proxy-error-handling.test.ts
|
|
378
|
-
├── proxy-mcp-filtering.test.ts
|
|
379
|
-
├── proxy-passthrough-concept.test.ts
|
|
380
|
-
├── proxy-pretooluse-hook.test.ts
|
|
381
|
-
├── proxy-session-resume.test.ts
|
|
382
|
-
├── proxy-streaming-message.test.ts
|
|
383
|
-
├── proxy-subagent-support.test.ts
|
|
384
|
-
├── proxy-tool-forwarding.test.ts
|
|
385
|
-
├── proxy-transparent-tools.test.ts
|
|
386
|
-
└── proxy-working-directory.test.ts
|
|
346
|
+
└── claude-max-headers.ts # OpenCode plugin for session header injection
|
|
387
347
|
```
|
|
388
348
|
|
|
349
|
+
## Disclaimer
|
|
350
|
+
|
|
351
|
+
This project is an **unofficial wrapper** around Anthropic's publicly available [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). It is not affiliated with, endorsed by, or supported by Anthropic.
|
|
352
|
+
|
|
353
|
+
**Use at your own risk.** The authors make no claims regarding compliance with Anthropic's Terms of Service. It is your responsibility to review and comply with [Anthropic's Terms of Service](https://www.anthropic.com/legal/consumer-terms) and [Authorized Usage Policy](https://www.anthropic.com/legal/aup). Terms may change at any time.
|
|
354
|
+
|
|
355
|
+
This project calls `query()` from Anthropic's public npm package using your own authenticated account. No API keys are intercepted, no authentication is bypassed, and no proprietary systems are reverse-engineered.
|
|
356
|
+
|
|
389
357
|
## License
|
|
390
358
|
|
|
391
359
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-claude-max-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Use your Claude Max subscription with OpenCode via proxy server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/proxy/server.ts",
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
"bin/",
|
|
33
33
|
"src/proxy/",
|
|
34
34
|
"src/logger.ts",
|
|
35
|
-
"
|
|
35
|
+
"src/mcpTools.ts",
|
|
36
|
+
"README.md",
|
|
37
|
+
"src/plugin/"
|
|
36
38
|
],
|
|
37
39
|
"keywords": [
|
|
38
40
|
"opencode",
|
package/src/mcpTools.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import * as fs from "node:fs/promises"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
import { exec } from "node:child_process"
|
|
6
|
+
import { promisify } from "node:util"
|
|
7
|
+
import { glob as globLib } from "glob"
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec)
|
|
10
|
+
|
|
11
|
+
const getCwd = () => process.env.CLAUDE_PROXY_WORKDIR || process.cwd()
|
|
12
|
+
|
|
13
|
+
export const opencodeMcpServer = createSdkMcpServer({
|
|
14
|
+
name: "opencode",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
tools: [
|
|
17
|
+
tool(
|
|
18
|
+
"read",
|
|
19
|
+
"Read the contents of a file at the specified path",
|
|
20
|
+
{
|
|
21
|
+
path: z.string().describe("Absolute or relative path to the file"),
|
|
22
|
+
encoding: z.string().optional().describe("File encoding, defaults to utf-8")
|
|
23
|
+
},
|
|
24
|
+
async (args) => {
|
|
25
|
+
try {
|
|
26
|
+
const filePath = path.isAbsolute(args.path)
|
|
27
|
+
? args.path
|
|
28
|
+
: path.resolve(getCwd(), args.path)
|
|
29
|
+
const content = await fs.readFile(filePath, (args.encoding || "utf-8") as BufferEncoding)
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: content }]
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
content: [{ type: "text", text: `Error reading file: ${error instanceof Error ? error.message : String(error)}` }],
|
|
36
|
+
isError: true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
tool(
|
|
43
|
+
"write",
|
|
44
|
+
"Write content to a file, creating directories if needed",
|
|
45
|
+
{
|
|
46
|
+
path: z.string().describe("Path to write to"),
|
|
47
|
+
content: z.string().describe("Content to write")
|
|
48
|
+
},
|
|
49
|
+
async (args) => {
|
|
50
|
+
try {
|
|
51
|
+
const filePath = path.isAbsolute(args.path)
|
|
52
|
+
? args.path
|
|
53
|
+
: path.resolve(getCwd(), args.path)
|
|
54
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
55
|
+
await fs.writeFile(filePath, args.content, "utf-8")
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text", text: `Successfully wrote to ${args.path}` }]
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text", text: `Error writing file: ${error instanceof Error ? error.message : String(error)}` }],
|
|
62
|
+
isError: true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
),
|
|
67
|
+
|
|
68
|
+
tool(
|
|
69
|
+
"edit",
|
|
70
|
+
"Edit a file by replacing oldString with newString",
|
|
71
|
+
{
|
|
72
|
+
path: z.string().describe("Path to the file to edit"),
|
|
73
|
+
oldString: z.string().describe("The text to replace"),
|
|
74
|
+
newString: z.string().describe("The replacement text")
|
|
75
|
+
},
|
|
76
|
+
async (args) => {
|
|
77
|
+
try {
|
|
78
|
+
const filePath = path.isAbsolute(args.path)
|
|
79
|
+
? args.path
|
|
80
|
+
: path.resolve(getCwd(), args.path)
|
|
81
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
82
|
+
if (!content.includes(args.oldString)) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: `Error: oldString not found in file` }],
|
|
85
|
+
isError: true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const newContent = content.replace(args.oldString, args.newString)
|
|
89
|
+
await fs.writeFile(filePath, newContent, "utf-8")
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text", text: `Successfully edited ${args.path}` }]
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: `Error editing file: ${error instanceof Error ? error.message : String(error)}` }],
|
|
96
|
+
isError: true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
),
|
|
101
|
+
|
|
102
|
+
tool(
|
|
103
|
+
"bash",
|
|
104
|
+
"Execute a bash command and return the output",
|
|
105
|
+
{
|
|
106
|
+
command: z.string().describe("The command to execute"),
|
|
107
|
+
cwd: z.string().optional().describe("Working directory for the command")
|
|
108
|
+
},
|
|
109
|
+
async (args) => {
|
|
110
|
+
try {
|
|
111
|
+
const options = {
|
|
112
|
+
cwd: args.cwd || getCwd(),
|
|
113
|
+
timeout: 120000
|
|
114
|
+
}
|
|
115
|
+
const { stdout, stderr } = await execAsync(args.command, options)
|
|
116
|
+
const output = stdout || stderr || "(no output)"
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: "text", text: output }]
|
|
119
|
+
}
|
|
120
|
+
} catch (error: unknown) {
|
|
121
|
+
const execError = error as { stdout?: string; stderr?: string; message?: string }
|
|
122
|
+
const output = execError.stdout || execError.stderr || execError.message || String(error)
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: output }],
|
|
125
|
+
isError: true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
),
|
|
130
|
+
|
|
131
|
+
tool(
|
|
132
|
+
"glob",
|
|
133
|
+
"Find files matching a glob pattern",
|
|
134
|
+
{
|
|
135
|
+
pattern: z.string().describe("Glob pattern like **/*.ts"),
|
|
136
|
+
cwd: z.string().optional().describe("Base directory for the search")
|
|
137
|
+
},
|
|
138
|
+
async (args) => {
|
|
139
|
+
try {
|
|
140
|
+
const files = await globLib(args.pattern, {
|
|
141
|
+
cwd: args.cwd || getCwd(),
|
|
142
|
+
nodir: true,
|
|
143
|
+
ignore: ["**/node_modules/**", "**/.git/**"]
|
|
144
|
+
})
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text: files.join("\n") || "(no matches)" }]
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
151
|
+
isError: true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
tool(
|
|
158
|
+
"grep",
|
|
159
|
+
"Search for a pattern in files",
|
|
160
|
+
{
|
|
161
|
+
pattern: z.string().describe("Regex pattern to search for"),
|
|
162
|
+
path: z.string().optional().describe("Directory or file to search in"),
|
|
163
|
+
include: z.string().optional().describe("File pattern to include, e.g., *.ts")
|
|
164
|
+
},
|
|
165
|
+
async (args) => {
|
|
166
|
+
try {
|
|
167
|
+
const searchPath = args.path || getCwd()
|
|
168
|
+
const includePattern = args.include || "*"
|
|
169
|
+
|
|
170
|
+
let cmd = `grep -rn --include="${includePattern}" "${args.pattern}" "${searchPath}" 2>/dev/null || true`
|
|
171
|
+
const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 })
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text", text: stdout || "(no matches)" }]
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
179
|
+
isError: true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
]
|
|
185
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode plugin that injects session tracking headers into Anthropic API requests.
|
|
3
|
+
*
|
|
4
|
+
* This enables the claude-max-proxy to reliably track sessions and resume
|
|
5
|
+
* Claude Agent SDK conversations instead of starting fresh every time.
|
|
6
|
+
*
|
|
7
|
+
* Installation:
|
|
8
|
+
* Add to your opencode.json:
|
|
9
|
+
* { "plugin": ["./path/to/claude-max-headers.ts"] }
|
|
10
|
+
*
|
|
11
|
+
* Or copy to your project's .opencode/plugin/ directory.
|
|
12
|
+
*
|
|
13
|
+
* What it does:
|
|
14
|
+
* Adds x-opencode-session and x-opencode-request headers to requests
|
|
15
|
+
* sent to the Anthropic provider. The proxy uses these to map OpenCode
|
|
16
|
+
* sessions to Claude SDK sessions for conversation resumption.
|
|
17
|
+
*
|
|
18
|
+
* Without this plugin:
|
|
19
|
+
* The proxy falls back to fingerprint-based session matching (hashing
|
|
20
|
+
* the first user message). This works but is less reliable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type ChatHeadersHook = (
|
|
24
|
+
incoming: {
|
|
25
|
+
sessionID: string
|
|
26
|
+
agent: any
|
|
27
|
+
model: { providerID: string }
|
|
28
|
+
provider: any
|
|
29
|
+
message: { id: string }
|
|
30
|
+
},
|
|
31
|
+
output: { headers: Record<string, string> }
|
|
32
|
+
) => Promise<void>
|
|
33
|
+
|
|
34
|
+
type PluginHooks = {
|
|
35
|
+
"chat.headers"?: ChatHeadersHook
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type PluginFn = (input: any) => Promise<PluginHooks>
|
|
39
|
+
|
|
40
|
+
export const ClaudeMaxHeadersPlugin: PluginFn = async (_input) => {
|
|
41
|
+
return {
|
|
42
|
+
"chat.headers": async (incoming, output) => {
|
|
43
|
+
// Only inject headers for Anthropic provider requests
|
|
44
|
+
if (incoming.model.providerID !== "anthropic") return
|
|
45
|
+
|
|
46
|
+
output.headers["x-opencode-session"] = incoming.sessionID
|
|
47
|
+
output.headers["x-opencode-request"] = incoming.message.id
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default ClaudeMaxHeadersPlugin
|
package/src/proxy/server.ts
CHANGED
|
@@ -118,7 +118,7 @@ function storeSession(
|
|
|
118
118
|
if (fp) fingerprintCache.set(fp, state)
|
|
119
119
|
// Shared file store (cross-proxy resume)
|
|
120
120
|
const key = opencodeSessionId || fp
|
|
121
|
-
if (key) storeSharedSession(key, claudeSessionId)
|
|
121
|
+
if (key) storeSharedSession(key, claudeSessionId, state.messageCount)
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/** Extract only the last user message (for resume — SDK already has history) */
|
|
@@ -415,34 +415,118 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
415
415
|
|
|
416
416
|
|
|
417
417
|
|
|
418
|
-
// When resuming, only send
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
: body.messages
|
|
418
|
+
// When resuming, only send new messages the SDK doesn't have.
|
|
419
|
+
const allMessages = body.messages || []
|
|
420
|
+
let messagesToConvert: typeof allMessages
|
|
422
421
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
422
|
+
if (isResume && cachedSession) {
|
|
423
|
+
const knownCount = cachedSession.messageCount || 0
|
|
424
|
+
if (knownCount > 0 && knownCount < allMessages.length) {
|
|
425
|
+
messagesToConvert = allMessages.slice(knownCount)
|
|
426
|
+
} else {
|
|
427
|
+
messagesToConvert = getLastUserMessage(allMessages)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
messagesToConvert = allMessages
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Check if any messages contain multimodal content (images, documents, files)
|
|
434
|
+
const MULTIMODAL_TYPES = new Set(["image", "document", "file"])
|
|
435
|
+
const hasMultimodal = messagesToConvert?.some((m: any) =>
|
|
436
|
+
Array.isArray(m.content) && m.content.some((b: any) => MULTIMODAL_TYPES.has(b.type))
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// Strip cache_control from content blocks — the SDK manages its own caching
|
|
440
|
+
// and OpenCode's ttl='1h' blocks conflict with the SDK's ttl='5m' blocks
|
|
441
|
+
function stripCacheControl(content: any): any {
|
|
442
|
+
if (!Array.isArray(content)) return content
|
|
443
|
+
return content.map((block: any) => {
|
|
444
|
+
if (block.cache_control) {
|
|
445
|
+
const { cache_control, ...rest } = block
|
|
446
|
+
return rest
|
|
447
|
+
}
|
|
448
|
+
return block
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Build the prompt — either structured (multimodal) or text
|
|
453
|
+
let prompt: string | AsyncIterable<any>
|
|
454
|
+
|
|
455
|
+
if (hasMultimodal) {
|
|
456
|
+
// Structured messages preserve image/document/file blocks for Claude to see.
|
|
457
|
+
// The SDK only accepts role:"user" in SDKUserMessage, so assistant messages
|
|
458
|
+
// are converted to text summaries wrapped as user messages.
|
|
459
|
+
const structured = messagesToConvert.map((m: any) => {
|
|
460
|
+
if (m.role === "user") {
|
|
461
|
+
return {
|
|
462
|
+
type: "user" as const,
|
|
463
|
+
message: { role: "user" as const, content: stripCacheControl(m.content) },
|
|
464
|
+
parent_tool_use_id: null,
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Convert assistant/tool messages to text summary
|
|
468
|
+
let text: string
|
|
428
469
|
if (typeof m.content === "string") {
|
|
429
|
-
|
|
470
|
+
text = `[Assistant: ${m.content}]`
|
|
430
471
|
} else if (Array.isArray(m.content)) {
|
|
431
|
-
|
|
432
|
-
.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
})
|
|
438
|
-
.filter(Boolean)
|
|
439
|
-
.join("\n")
|
|
472
|
+
text = m.content.map((b: any) => {
|
|
473
|
+
if (b.type === "text" && b.text) return `[Assistant: ${b.text}]`
|
|
474
|
+
if (b.type === "tool_use") return `[Tool Use: ${b.name}(${JSON.stringify(b.input)})]`
|
|
475
|
+
if (b.type === "tool_result") return `[Tool Result: ${typeof b.content === "string" ? b.content : JSON.stringify(b.content)}]`
|
|
476
|
+
return ""
|
|
477
|
+
}).filter(Boolean).join("\n")
|
|
440
478
|
} else {
|
|
441
|
-
|
|
479
|
+
text = `[Assistant: ${String(m.content)}]`
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
type: "user" as const,
|
|
483
|
+
message: { role: "user" as const, content: text },
|
|
484
|
+
parent_tool_use_id: null,
|
|
442
485
|
}
|
|
443
|
-
return `${role}: ${content}`
|
|
444
486
|
})
|
|
445
|
-
|
|
487
|
+
|
|
488
|
+
// Prepend system context as a text message
|
|
489
|
+
if (systemContext) {
|
|
490
|
+
structured.unshift({
|
|
491
|
+
type: "user" as const,
|
|
492
|
+
message: { role: "user", content: systemContext },
|
|
493
|
+
parent_tool_use_id: null,
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
prompt = (async function* () { for (const msg of structured) yield msg })()
|
|
498
|
+
} else {
|
|
499
|
+
// Text prompt — convert messages to string
|
|
500
|
+
const conversationParts = messagesToConvert
|
|
501
|
+
?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
|
|
502
|
+
const role = m.role === "assistant" ? "Assistant" : "Human"
|
|
503
|
+
let content: string
|
|
504
|
+
if (typeof m.content === "string") {
|
|
505
|
+
content = m.content
|
|
506
|
+
} else if (Array.isArray(m.content)) {
|
|
507
|
+
content = m.content
|
|
508
|
+
.map((block: any) => {
|
|
509
|
+
if (block.type === "text" && block.text) return block.text
|
|
510
|
+
if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
|
|
511
|
+
if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
|
|
512
|
+
if (block.type === "image") return "[Image attached]"
|
|
513
|
+
if (block.type === "document") return "[Document attached]"
|
|
514
|
+
if (block.type === "file") return "[File attached]"
|
|
515
|
+
return ""
|
|
516
|
+
})
|
|
517
|
+
.filter(Boolean)
|
|
518
|
+
.join("\n")
|
|
519
|
+
} else {
|
|
520
|
+
content = String(m.content)
|
|
521
|
+
}
|
|
522
|
+
return `${role}: ${content}`
|
|
523
|
+
})
|
|
524
|
+
.join("\n\n") || ""
|
|
525
|
+
|
|
526
|
+
prompt = systemContext
|
|
527
|
+
? `${systemContext}\n\n${conversationParts}`
|
|
528
|
+
: conversationParts
|
|
529
|
+
}
|
|
446
530
|
|
|
447
531
|
// --- Passthrough mode ---
|
|
448
532
|
// When enabled, ALL tool execution is forwarded to OpenCode instead of
|
|
@@ -499,10 +583,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
499
583
|
}
|
|
500
584
|
: undefined
|
|
501
585
|
|
|
502
|
-
|
|
503
|
-
const prompt = systemContext
|
|
504
|
-
? `${systemContext}\n\n${conversationParts}`
|
|
505
|
-
: conversationParts
|
|
586
|
+
|
|
506
587
|
|
|
507
588
|
if (!stream) {
|
|
508
589
|
const contentBlocks: Array<Record<string, unknown>> = []
|
|
@@ -1034,6 +1115,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
1034
1115
|
}
|
|
1035
1116
|
})
|
|
1036
1117
|
|
|
1118
|
+
// Catch-all: log unhandled requests
|
|
1119
|
+
app.all("*", (c) => {
|
|
1120
|
+
console.error(`[PROXY] UNHANDLED ${c.req.method} ${c.req.url}`)
|
|
1121
|
+
return c.json({ error: { type: "not_found", message: `Endpoint not supported: ${c.req.method} ${new URL(c.req.url).pathname}` } }, 404)
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1037
1124
|
return { app, config: finalConfig }
|
|
1038
1125
|
}
|
|
1039
1126
|
|
|
@@ -18,6 +18,7 @@ export interface StoredSession {
|
|
|
18
18
|
claudeSessionId: string
|
|
19
19
|
createdAt: number
|
|
20
20
|
lastUsedAt: number
|
|
21
|
+
messageCount: number
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
@@ -73,13 +74,14 @@ export function lookupSharedSession(key: string): StoredSession | undefined {
|
|
|
73
74
|
return session
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export function storeSharedSession(key: string, claudeSessionId: string): void {
|
|
77
|
+
export function storeSharedSession(key: string, claudeSessionId: string, messageCount?: number): void {
|
|
77
78
|
const store = readStore()
|
|
78
79
|
const existing = store[key]
|
|
79
80
|
store[key] = {
|
|
80
81
|
claudeSessionId,
|
|
81
82
|
createdAt: existing?.createdAt || Date.now(),
|
|
82
83
|
lastUsedAt: Date.now(),
|
|
84
|
+
messageCount: messageCount ?? existing?.messageCount ?? 0,
|
|
83
85
|
}
|
|
84
86
|
writeStore(store)
|
|
85
87
|
}
|