opencode-claude-max-proxy 1.0.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 ADDED
@@ -0,0 +1,205 @@
1
+ # opencode-claude-max-proxy
2
+
3
+ [![npm version](https://img.shields.io/npm/v/opencode-claude-max-proxy.svg)](https://www.npmjs.com/package/opencode-claude-max-proxy)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![GitHub stars](https://img.shields.io/github/stars/rynfar/opencode-claude-max-proxy.svg)](https://github.com/rynfar/opencode-claude-max-proxy/stargazers)
6
+
7
+ Use your **Claude Max subscription** with OpenCode.
8
+
9
+ ## The Problem
10
+
11
+ Anthropic doesn't allow Claude Max subscribers to use their subscription with third-party tools like OpenCode. If you want to use Claude in OpenCode, you have to pay for API access separately - even though you're already paying for "unlimited" Claude.
12
+
13
+ Your options are:
14
+ 1. Use Claude's official apps only (limited to their UI)
15
+ 2. Pay again for API access on top of your Max subscription
16
+ 3. **Use this proxy**
17
+
18
+ ## The Solution
19
+
20
+ This proxy bridges the gap using Anthropic's own tools:
21
+
22
+ ```
23
+ OpenCode → Proxy (localhost:3456) → Claude Agent SDK → Your Claude Max Subscription
24
+ ```
25
+
26
+ The [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) is Anthropic's **official npm package** that lets developers build with Claude using their Max subscription. This proxy simply translates OpenCode's API requests into SDK calls.
27
+
28
+ **Your Max subscription. Anthropic's official SDK. Zero additional cost.**
29
+
30
+ ## Is This Allowed?
31
+
32
+ **Yes.** Here's why:
33
+
34
+ | Concern | Reality |
35
+ |---------|---------|
36
+ | "Bypassing restrictions" | No. We use Anthropic's public SDK exactly as documented |
37
+ | "Violating TOS" | No. The SDK is designed for programmatic Claude access |
38
+ | "Unauthorized access" | No. You authenticate with `claude login` using your own account |
39
+ | "Reverse engineering" | No. We call `query()` from their npm package, that's it |
40
+
41
+ The Claude Agent SDK exists specifically to let Max subscribers use Claude programmatically. We're just translating the request format so OpenCode can use it.
42
+
43
+ **~200 lines of TypeScript. No hacks. No magic. Just format translation.**
44
+
45
+ ## Features
46
+
47
+ | Feature | Description |
48
+ |---------|-------------|
49
+ | **Zero API costs** | Uses your Claude Max subscription, not per-token billing |
50
+ | **Full compatibility** | Works with any Anthropic model in OpenCode |
51
+ | **Streaming support** | Real-time SSE streaming just like the real API |
52
+ | **Auto-start** | Optional launchd service for macOS |
53
+ | **Simple setup** | Two commands to get running |
54
+
55
+ ## Prerequisites
56
+
57
+ 1. **Claude Max subscription** - [Subscribe here](https://claude.ai/settings/subscription)
58
+
59
+ 2. **Claude CLI** installed and authenticated:
60
+ ```bash
61
+ npm install -g @anthropic-ai/claude-code
62
+ claude login
63
+ ```
64
+
65
+ 3. **Bun** runtime:
66
+ ```bash
67
+ curl -fsSL https://bun.sh/install | bash
68
+ ```
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ git clone https://github.com/rynfar/opencode-claude-max-proxy
74
+ cd opencode-claude-max-proxy
75
+ bun install
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ### Start the Proxy
81
+
82
+ ```bash
83
+ bun run proxy
84
+ ```
85
+
86
+ ### Run OpenCode
87
+
88
+ ```bash
89
+ ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
90
+ ```
91
+
92
+ Select any `anthropic/claude-*` model (opus, sonnet, haiku).
93
+
94
+ ### One-liner
95
+
96
+ ```bash
97
+ bun run proxy & ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode
98
+ ```
99
+
100
+ ## Auto-start on macOS
101
+
102
+ Set up the proxy to run automatically on login:
103
+
104
+ ```bash
105
+ cat > ~/Library/LaunchAgents/com.claude-max-proxy.plist << EOF
106
+ <?xml version="1.0" encoding="UTF-8"?>
107
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
108
+ <plist version="1.0">
109
+ <dict>
110
+ <key>Label</key>
111
+ <string>com.claude-max-proxy</string>
112
+ <key>ProgramArguments</key>
113
+ <array>
114
+ <string>$(which bun)</string>
115
+ <string>run</string>
116
+ <string>proxy</string>
117
+ </array>
118
+ <key>WorkingDirectory</key>
119
+ <string>$(pwd)</string>
120
+ <key>RunAtLoad</key>
121
+ <true/>
122
+ <key>KeepAlive</key>
123
+ <true/>
124
+ </dict>
125
+ </plist>
126
+ EOF
127
+
128
+ launchctl load ~/Library/LaunchAgents/com.claude-max-proxy.plist
129
+ ```
130
+
131
+ Then add an alias to `~/.zshrc`:
132
+
133
+ ```bash
134
+ echo "alias oc='ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode'" >> ~/.zshrc
135
+ source ~/.zshrc
136
+ ```
137
+
138
+ Now just run `oc` to start OpenCode with Claude Max.
139
+
140
+ ## Model Mapping
141
+
142
+ | OpenCode Model | Claude SDK |
143
+ |----------------|------------|
144
+ | `anthropic/claude-opus-*` | opus |
145
+ | `anthropic/claude-sonnet-*` | sonnet |
146
+ | `anthropic/claude-haiku-*` | haiku |
147
+
148
+ ## Configuration
149
+
150
+ | Environment Variable | Default | Description |
151
+ |---------------------|---------|-------------|
152
+ | `CLAUDE_PROXY_PORT` | 3456 | Proxy server port |
153
+ | `CLAUDE_PROXY_HOST` | 127.0.0.1 | Proxy server host |
154
+
155
+ ## How It Works
156
+
157
+ 1. **OpenCode** sends a request to `http://127.0.0.1:3456/messages` (thinking it's the Anthropic API)
158
+ 2. **Proxy** receives the request and extracts the messages
159
+ 3. **Proxy** calls `query()` from the Claude Agent SDK with your prompt
160
+ 4. **Claude Agent SDK** authenticates using your Claude CLI login (tied to your Max subscription)
161
+ 5. **Claude** processes the request using your subscription
162
+ 6. **Proxy** streams the response back in Anthropic SSE format
163
+ 7. **OpenCode** receives the response as if it came from the real API
164
+
165
+ The proxy is ~200 lines of TypeScript. No magic, no hacks.
166
+
167
+ ## FAQ
168
+
169
+ ### Why do I need `ANTHROPIC_API_KEY=dummy`?
170
+
171
+ OpenCode requires an API key to be set, but we never actually use it. The Claude Agent SDK handles authentication through your Claude CLI login. Any non-empty string works.
172
+
173
+ ### Does this work with other tools besides OpenCode?
174
+
175
+ Yes! Any tool that uses the Anthropic API format can use this proxy. Just point `ANTHROPIC_BASE_URL` to `http://127.0.0.1:3456`.
176
+
177
+ ### What about rate limits?
178
+
179
+ Your Claude Max subscription has its own usage limits. This proxy doesn't add any additional limits.
180
+
181
+ ### Is my data sent anywhere else?
182
+
183
+ No. The proxy runs locally on your machine. Your requests go directly to Claude through the official SDK.
184
+
185
+ ## Troubleshooting
186
+
187
+ ### "Authentication failed"
188
+
189
+ Run `claude login` to authenticate with the Claude CLI.
190
+
191
+ ### "Connection refused"
192
+
193
+ Make sure the proxy is running: `bun run proxy`
194
+
195
+ ### Proxy keeps dying
196
+
197
+ Use the launchd service (see Auto-start section) which automatically restarts the proxy.
198
+
199
+ ## License
200
+
201
+ MIT
202
+
203
+ ## Credits
204
+
205
+ Built with the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) by Anthropic.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { startProxyServer } from "../src/proxy/server"
4
+
5
+ const port = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10)
6
+ const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1"
7
+
8
+ await startProxyServer({ port, host })
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "opencode-claude-max-proxy",
3
+ "version": "1.0.0",
4
+ "description": "Use your Claude Max subscription with OpenCode via proxy server",
5
+ "type": "module",
6
+ "main": "./src/proxy/server.ts",
7
+ "bin": {
8
+ "claude-max-proxy": "./bin/claude-proxy.ts"
9
+ },
10
+ "exports": {
11
+ ".": "./src/proxy/server.ts"
12
+ },
13
+ "scripts": {
14
+ "start": "bun run ./bin/claude-proxy.ts",
15
+ "proxy": "bun run ./bin/claude-proxy.ts",
16
+ "test": "bun test",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@anthropic-ai/claude-agent-sdk": "^0.2.0",
21
+ "hono": "^4.11.4"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "^1.2.21",
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.8.2"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "src/proxy/",
31
+ "src/logger.ts",
32
+ "README.md"
33
+ ],
34
+ "keywords": [
35
+ "opencode",
36
+ "opencode-ai",
37
+ "opencode-plugin",
38
+ "claude",
39
+ "claude-max",
40
+ "claude-code",
41
+ "anthropic",
42
+ "ai-coding",
43
+ "ai-assistant",
44
+ "proxy",
45
+ "claude-agent-sdk",
46
+ "llm",
47
+ "coding-assistant"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/rynfar/opencode-claude-max-proxy.git"
52
+ },
53
+ "homepage": "https://github.com/rynfar/opencode-claude-max-proxy#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/rynfar/opencode-claude-max-proxy/issues"
56
+ },
57
+ "author": "rynfar",
58
+ "license": "MIT",
59
+ "private": false
60
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,10 @@
1
+ const shouldLog = () => process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"]
2
+
3
+ export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
4
+ if (!shouldLog()) return
5
+ const parts = ["[opencode-claude-code-provider]", message]
6
+ if (extra && Object.keys(extra).length > 0) {
7
+ parts.push(JSON.stringify(extra))
8
+ }
9
+ console.debug(parts.join(" "))
10
+ }
@@ -0,0 +1,193 @@
1
+ import { Hono } from "hono"
2
+ import { cors } from "hono/cors"
3
+ import { query } from "@anthropic-ai/claude-agent-sdk"
4
+ import type { Context } from "hono"
5
+ import type { ProxyConfig } from "./types"
6
+ import { DEFAULT_PROXY_CONFIG } from "./types"
7
+ import { claudeLog } from "../logger"
8
+
9
+ function mapModelToClaudeModel(model: string): "sonnet" | "opus" | "haiku" {
10
+ if (model.includes("opus")) return "opus"
11
+ if (model.includes("haiku")) return "haiku"
12
+ return "sonnet"
13
+ }
14
+
15
+ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
16
+ const finalConfig = { ...DEFAULT_PROXY_CONFIG, ...config }
17
+ const app = new Hono()
18
+
19
+ app.use("*", cors())
20
+
21
+ app.get("/", (c) => {
22
+ return c.json({
23
+ status: "ok",
24
+ service: "claude-max-proxy",
25
+ version: "1.0.0",
26
+ format: "anthropic",
27
+ endpoints: ["/v1/messages", "/messages"]
28
+ })
29
+ })
30
+
31
+ const handleMessages = async (c: Context) => {
32
+ try {
33
+ const body = await c.req.json()
34
+ const model = mapModelToClaudeModel(body.model || "sonnet")
35
+ const stream = body.stream ?? true
36
+
37
+ claudeLog("proxy.anthropic.request", { model, stream, messageCount: body.messages?.length })
38
+
39
+ const prompt = body.messages
40
+ ?.map((m: { role: string; content: string | Array<{ type: string; text?: string }> }) => {
41
+ const role = m.role === "assistant" ? "Assistant" : "Human"
42
+ let content: string
43
+ if (typeof m.content === "string") {
44
+ content = m.content
45
+ } else if (Array.isArray(m.content)) {
46
+ content = m.content
47
+ .filter((block) => block.type === "text" && block.text)
48
+ .map((block) => block.text)
49
+ .join("")
50
+ } else {
51
+ content = String(m.content)
52
+ }
53
+ return `${role}: ${content}`
54
+ })
55
+ .join("\n\n") || ""
56
+
57
+ if (!stream) {
58
+ let fullContent = ""
59
+ const response = query({
60
+ prompt,
61
+ options: { maxTurns: 1, model }
62
+ })
63
+
64
+ for await (const message of response) {
65
+ if (message.type === "assistant") {
66
+ for (const block of message.message.content) {
67
+ if (block.type === "text") {
68
+ fullContent += block.text
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ return c.json({
75
+ id: `msg_${Date.now()}`,
76
+ type: "message",
77
+ role: "assistant",
78
+ content: [{ type: "text", text: fullContent }],
79
+ model: body.model,
80
+ stop_reason: "end_turn",
81
+ usage: { input_tokens: 0, output_tokens: 0 }
82
+ })
83
+ }
84
+
85
+ const encoder = new TextEncoder()
86
+ const readable = new ReadableStream({
87
+ async start(controller) {
88
+ try {
89
+ controller.enqueue(encoder.encode(`event: message_start\ndata: ${JSON.stringify({
90
+ type: "message_start",
91
+ message: {
92
+ id: `msg_${Date.now()}`,
93
+ type: "message",
94
+ role: "assistant",
95
+ content: [],
96
+ model: body.model,
97
+ stop_reason: null,
98
+ usage: { input_tokens: 0, output_tokens: 0 }
99
+ }
100
+ })}\n\n`))
101
+
102
+ controller.enqueue(encoder.encode(`event: content_block_start\ndata: ${JSON.stringify({
103
+ type: "content_block_start",
104
+ index: 0,
105
+ content_block: { type: "text", text: "" }
106
+ })}\n\n`))
107
+
108
+ const response = query({
109
+ prompt,
110
+ options: { maxTurns: 1, model }
111
+ })
112
+
113
+ for await (const message of response) {
114
+ if (message.type === "assistant") {
115
+ for (const block of message.message.content) {
116
+ if (block.type === "text") {
117
+ controller.enqueue(encoder.encode(`event: content_block_delta\ndata: ${JSON.stringify({
118
+ type: "content_block_delta",
119
+ index: 0,
120
+ delta: { type: "text_delta", text: block.text }
121
+ })}\n\n`))
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ controller.enqueue(encoder.encode(`event: content_block_stop\ndata: ${JSON.stringify({
128
+ type: "content_block_stop",
129
+ index: 0
130
+ })}\n\n`))
131
+
132
+ controller.enqueue(encoder.encode(`event: message_delta\ndata: ${JSON.stringify({
133
+ type: "message_delta",
134
+ delta: { stop_reason: "end_turn" },
135
+ usage: { output_tokens: 0 }
136
+ })}\n\n`))
137
+
138
+ controller.enqueue(encoder.encode(`event: message_stop\ndata: ${JSON.stringify({
139
+ type: "message_stop"
140
+ })}\n\n`))
141
+
142
+ controller.close()
143
+ } catch (error) {
144
+ claudeLog("proxy.anthropic.error", { error: error instanceof Error ? error.message : String(error) })
145
+ controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({
146
+ type: "error",
147
+ error: { type: "api_error", message: error instanceof Error ? error.message : "Unknown error" }
148
+ })}\n\n`))
149
+ controller.close()
150
+ }
151
+ }
152
+ })
153
+
154
+ return new Response(readable, {
155
+ headers: {
156
+ "Content-Type": "text/event-stream",
157
+ "Cache-Control": "no-cache",
158
+ Connection: "keep-alive"
159
+ }
160
+ })
161
+ } catch (error) {
162
+ claudeLog("proxy.error", { error: error instanceof Error ? error.message : String(error) })
163
+ return c.json({
164
+ type: "error",
165
+ error: {
166
+ type: "api_error",
167
+ message: error instanceof Error ? error.message : "Unknown error"
168
+ }
169
+ }, 500)
170
+ }
171
+ }
172
+
173
+ app.post("/v1/messages", handleMessages)
174
+ app.post("/messages", handleMessages)
175
+
176
+ return { app, config: finalConfig }
177
+ }
178
+
179
+ export async function startProxyServer(config: Partial<ProxyConfig> = {}) {
180
+ const { app, config: finalConfig } = createProxyServer(config)
181
+
182
+ const server = Bun.serve({
183
+ port: finalConfig.port,
184
+ hostname: finalConfig.host,
185
+ fetch: app.fetch
186
+ })
187
+
188
+ console.log(`Claude Max Proxy (Anthropic API) running at http://${finalConfig.host}:${finalConfig.port}`)
189
+ console.log(`\nTo use with OpenCode, run:`)
190
+ console.log(` ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://${finalConfig.host}:${finalConfig.port} opencode`)
191
+
192
+ return server
193
+ }
@@ -0,0 +1,11 @@
1
+ export interface ProxyConfig {
2
+ port: number
3
+ host: string
4
+ debug: boolean
5
+ }
6
+
7
+ export const DEFAULT_PROXY_CONFIG: ProxyConfig = {
8
+ port: 3456,
9
+ host: "127.0.0.1",
10
+ debug: process.env.CLAUDE_PROXY_DEBUG === "1"
11
+ }