opencode-claude-max-proxy 1.10.1 → 1.11.1
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/package.json +4 -2
- package/src/mcpTools.ts +185 -0
- package/src/plugin/claude-max-headers.ts +52 -0
- package/src/proxy/server.ts +133 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-claude-max-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
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
|
@@ -415,34 +415,134 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
415
415
|
|
|
416
416
|
|
|
417
417
|
|
|
418
|
-
// When resuming, only send
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
418
|
+
// When resuming, only send new messages the SDK doesn't have.
|
|
419
|
+
const allMessages = body.messages || []
|
|
420
|
+
let messagesToConvert: typeof allMessages
|
|
421
|
+
|
|
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
|
|
442
447
|
}
|
|
443
|
-
return
|
|
448
|
+
return block
|
|
444
449
|
})
|
|
445
|
-
|
|
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
|
+
// On resume, only send user messages (SDK has assistant context already).
|
|
458
|
+
// On first request, include everything.
|
|
459
|
+
const structured: Array<{ type: "user"; message: { role: string; content: any }; parent_tool_use_id: null }> = []
|
|
460
|
+
|
|
461
|
+
if (isResume) {
|
|
462
|
+
// Resume: only send user messages from the delta (SDK has the rest)
|
|
463
|
+
for (const m of messagesToConvert) {
|
|
464
|
+
if (m.role === "user") {
|
|
465
|
+
structured.push({
|
|
466
|
+
type: "user" as const,
|
|
467
|
+
message: { role: "user" as const, content: stripCacheControl(m.content) },
|
|
468
|
+
parent_tool_use_id: null,
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
// First request: include system context + all messages
|
|
474
|
+
if (systemContext) {
|
|
475
|
+
structured.push({
|
|
476
|
+
type: "user" as const,
|
|
477
|
+
message: { role: "user", content: systemContext },
|
|
478
|
+
parent_tool_use_id: null,
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
for (const m of messagesToConvert) {
|
|
482
|
+
if (m.role === "user") {
|
|
483
|
+
structured.push({
|
|
484
|
+
type: "user" as const,
|
|
485
|
+
message: { role: "user" as const, content: stripCacheControl(m.content) },
|
|
486
|
+
parent_tool_use_id: null,
|
|
487
|
+
})
|
|
488
|
+
} else {
|
|
489
|
+
// Convert assistant messages to text summaries
|
|
490
|
+
let text: string
|
|
491
|
+
if (typeof m.content === "string") {
|
|
492
|
+
text = `[Assistant: ${m.content}]`
|
|
493
|
+
} else if (Array.isArray(m.content)) {
|
|
494
|
+
text = m.content.map((b: any) => {
|
|
495
|
+
if (b.type === "text" && b.text) return `[Assistant: ${b.text}]`
|
|
496
|
+
if (b.type === "tool_use") return `[Tool Use: ${b.name}(${JSON.stringify(b.input)})]`
|
|
497
|
+
if (b.type === "tool_result") return `[Tool Result: ${typeof b.content === "string" ? b.content : JSON.stringify(b.content)}]`
|
|
498
|
+
return ""
|
|
499
|
+
}).filter(Boolean).join("\n")
|
|
500
|
+
} else {
|
|
501
|
+
text = `[Assistant: ${String(m.content)}]`
|
|
502
|
+
}
|
|
503
|
+
structured.push({
|
|
504
|
+
type: "user" as const,
|
|
505
|
+
message: { role: "user" as const, content: text },
|
|
506
|
+
parent_tool_use_id: null,
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
prompt = (async function* () { for (const msg of structured) yield msg })()
|
|
513
|
+
} else {
|
|
514
|
+
// Text prompt — convert messages to string
|
|
515
|
+
const conversationParts = messagesToConvert
|
|
516
|
+
?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
|
|
517
|
+
const role = m.role === "assistant" ? "Assistant" : "Human"
|
|
518
|
+
let content: string
|
|
519
|
+
if (typeof m.content === "string") {
|
|
520
|
+
content = m.content
|
|
521
|
+
} else if (Array.isArray(m.content)) {
|
|
522
|
+
content = m.content
|
|
523
|
+
.map((block: any) => {
|
|
524
|
+
if (block.type === "text" && block.text) return block.text
|
|
525
|
+
if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
|
|
526
|
+
if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
|
|
527
|
+
if (block.type === "image") return "[Image attached]"
|
|
528
|
+
if (block.type === "document") return "[Document attached]"
|
|
529
|
+
if (block.type === "file") return "[File attached]"
|
|
530
|
+
return ""
|
|
531
|
+
})
|
|
532
|
+
.filter(Boolean)
|
|
533
|
+
.join("\n")
|
|
534
|
+
} else {
|
|
535
|
+
content = String(m.content)
|
|
536
|
+
}
|
|
537
|
+
return `${role}: ${content}`
|
|
538
|
+
})
|
|
539
|
+
.join("\n\n") || ""
|
|
540
|
+
|
|
541
|
+
// On resume, skip system context (SDK already has it)
|
|
542
|
+
prompt = (!isResume && systemContext)
|
|
543
|
+
? `${systemContext}\n\n${conversationParts}`
|
|
544
|
+
: conversationParts
|
|
545
|
+
}
|
|
446
546
|
|
|
447
547
|
// --- Passthrough mode ---
|
|
448
548
|
// When enabled, ALL tool execution is forwarded to OpenCode instead of
|
|
@@ -499,10 +599,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
499
599
|
}
|
|
500
600
|
: undefined
|
|
501
601
|
|
|
502
|
-
|
|
503
|
-
const prompt = systemContext
|
|
504
|
-
? `${systemContext}\n\n${conversationParts}`
|
|
505
|
-
: conversationParts
|
|
602
|
+
|
|
506
603
|
|
|
507
604
|
if (!stream) {
|
|
508
605
|
const contentBlocks: Array<Record<string, unknown>> = []
|
|
@@ -1034,6 +1131,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
1034
1131
|
}
|
|
1035
1132
|
})
|
|
1036
1133
|
|
|
1134
|
+
// Catch-all: log unhandled requests
|
|
1135
|
+
app.all("*", (c) => {
|
|
1136
|
+
console.error(`[PROXY] UNHANDLED ${c.req.method} ${c.req.url}`)
|
|
1137
|
+
return c.json({ error: { type: "not_found", message: `Endpoint not supported: ${c.req.method} ${new URL(c.req.url).pathname}` } }, 404)
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1037
1140
|
return { app, config: finalConfig }
|
|
1038
1141
|
}
|
|
1039
1142
|
|