opencode-claude-max-proxy 1.0.2 → 1.8.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 +272 -113
- package/bin/claude-proxy-supervisor.sh +62 -0
- package/bin/claude-proxy.ts +26 -1
- package/package.json +9 -6
- package/src/logger.ts +68 -6
- package/src/proxy/agentDefs.ts +102 -0
- package/src/proxy/agentMatch.ts +93 -0
- package/src/proxy/passthroughTools.ts +108 -0
- package/src/proxy/server.ts +947 -118
- package/src/proxy/types.ts +3 -1
package/src/proxy/server.ts
CHANGED
|
@@ -5,6 +5,250 @@ import type { Context } from "hono"
|
|
|
5
5
|
import type { ProxyConfig } from "./types"
|
|
6
6
|
import { DEFAULT_PROXY_CONFIG } from "./types"
|
|
7
7
|
import { claudeLog } from "../logger"
|
|
8
|
+
import { execSync } from "child_process"
|
|
9
|
+
import { existsSync } from "fs"
|
|
10
|
+
import { fileURLToPath } from "url"
|
|
11
|
+
import { join, dirname } from "path"
|
|
12
|
+
import { opencodeMcpServer } from "../mcpTools"
|
|
13
|
+
import { randomUUID, createHash } from "crypto"
|
|
14
|
+
import { withClaudeLogContext } from "../logger"
|
|
15
|
+
import { fuzzyMatchAgentName } from "./agentMatch"
|
|
16
|
+
import { buildAgentDefinitions } from "./agentDefs"
|
|
17
|
+
import { createPassthroughMcpServer, stripMcpPrefix, PASSTHROUGH_MCP_NAME, PASSTHROUGH_MCP_PREFIX } from "./passthroughTools"
|
|
18
|
+
|
|
19
|
+
// --- Session Tracking ---
|
|
20
|
+
// Maps OpenCode session ID (or fingerprint) → Claude SDK session ID
|
|
21
|
+
interface SessionState {
|
|
22
|
+
claudeSessionId: string
|
|
23
|
+
lastAccess: number
|
|
24
|
+
messageCount: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sessionCache = new Map<string, SessionState>()
|
|
28
|
+
const fingerprintCache = new Map<string, SessionState>()
|
|
29
|
+
|
|
30
|
+
/** Clear all session caches (used in tests) */
|
|
31
|
+
export function clearSessionCache() {
|
|
32
|
+
sessionCache.clear()
|
|
33
|
+
fingerprintCache.clear()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Clean stale sessions every hour — sessions survive a full workday
|
|
37
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
38
|
+
setInterval(() => {
|
|
39
|
+
const now = Date.now()
|
|
40
|
+
for (const [key, val] of sessionCache) {
|
|
41
|
+
if (now - val.lastAccess > SESSION_TTL_MS) sessionCache.delete(key)
|
|
42
|
+
}
|
|
43
|
+
for (const [key, val] of fingerprintCache) {
|
|
44
|
+
if (now - val.lastAccess > SESSION_TTL_MS) fingerprintCache.delete(key)
|
|
45
|
+
}
|
|
46
|
+
}, 60 * 60 * 1000)
|
|
47
|
+
|
|
48
|
+
/** Hash the first user message to fingerprint a conversation */
|
|
49
|
+
function getConversationFingerprint(messages: Array<{ role: string; content: any }>): string {
|
|
50
|
+
const firstUser = messages?.find((m) => m.role === "user")
|
|
51
|
+
if (!firstUser) return ""
|
|
52
|
+
const text = typeof firstUser.content === "string"
|
|
53
|
+
? firstUser.content
|
|
54
|
+
: Array.isArray(firstUser.content)
|
|
55
|
+
? firstUser.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
56
|
+
: ""
|
|
57
|
+
if (!text) return ""
|
|
58
|
+
return createHash("sha256").update(text.slice(0, 2000)).digest("hex").slice(0, 16)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Look up a cached session by header or fingerprint */
|
|
62
|
+
function lookupSession(
|
|
63
|
+
opencodeSessionId: string | undefined,
|
|
64
|
+
messages: Array<{ role: string; content: any }>
|
|
65
|
+
): SessionState | undefined {
|
|
66
|
+
// Primary: use x-opencode-session header
|
|
67
|
+
if (opencodeSessionId) {
|
|
68
|
+
return sessionCache.get(opencodeSessionId)
|
|
69
|
+
}
|
|
70
|
+
// Fallback: fingerprint (only when no header is present)
|
|
71
|
+
const fp = getConversationFingerprint(messages)
|
|
72
|
+
if (fp) return fingerprintCache.get(fp)
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Store a session mapping */
|
|
77
|
+
function storeSession(
|
|
78
|
+
opencodeSessionId: string | undefined,
|
|
79
|
+
messages: Array<{ role: string; content: any }>,
|
|
80
|
+
claudeSessionId: string
|
|
81
|
+
) {
|
|
82
|
+
if (!claudeSessionId) return
|
|
83
|
+
const state: SessionState = { claudeSessionId, lastAccess: Date.now(), messageCount: messages?.length || 0 }
|
|
84
|
+
if (opencodeSessionId) sessionCache.set(opencodeSessionId, state)
|
|
85
|
+
const fp = getConversationFingerprint(messages)
|
|
86
|
+
if (fp) fingerprintCache.set(fp, state)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Extract only the last user message (for resume — SDK already has history) */
|
|
90
|
+
function getLastUserMessage(messages: Array<{ role: string; content: any }>): Array<{ role: string; content: any }> {
|
|
91
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
92
|
+
if (messages[i]?.role === "user") return [messages[i]!]
|
|
93
|
+
}
|
|
94
|
+
return messages.slice(-1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Error Classification ---
|
|
98
|
+
// Detect specific SDK errors and return helpful messages to the client
|
|
99
|
+
function classifyError(errMsg: string): { status: number; type: string; message: string } {
|
|
100
|
+
const lower = errMsg.toLowerCase()
|
|
101
|
+
|
|
102
|
+
// Authentication failures
|
|
103
|
+
if (lower.includes("401") || lower.includes("authentication") || lower.includes("invalid auth") || lower.includes("credentials")) {
|
|
104
|
+
return {
|
|
105
|
+
status: 401,
|
|
106
|
+
type: "authentication_error",
|
|
107
|
+
message: "Claude authentication expired or invalid. Run 'claude login' in your terminal to re-authenticate, then restart the proxy."
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rate limiting
|
|
112
|
+
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests")) {
|
|
113
|
+
return {
|
|
114
|
+
status: 429,
|
|
115
|
+
type: "rate_limit_error",
|
|
116
|
+
message: "Claude Max rate limit reached. Wait a moment and try again."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Billing / subscription
|
|
121
|
+
if (lower.includes("402") || lower.includes("billing") || lower.includes("subscription") || lower.includes("payment")) {
|
|
122
|
+
return {
|
|
123
|
+
status: 402,
|
|
124
|
+
type: "billing_error",
|
|
125
|
+
message: "Claude Max subscription issue. Check your subscription status at https://claude.ai/settings/subscription"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SDK process crash
|
|
130
|
+
if (lower.includes("exited with code") || lower.includes("process exited")) {
|
|
131
|
+
const codeMatch = errMsg.match(/exited with code (\d+)/)
|
|
132
|
+
const code = codeMatch ? codeMatch[1] : "unknown"
|
|
133
|
+
|
|
134
|
+
// Code 1 with no other info is usually auth
|
|
135
|
+
if (code === "1" && !lower.includes("tool") && !lower.includes("mcp")) {
|
|
136
|
+
return {
|
|
137
|
+
status: 401,
|
|
138
|
+
type: "authentication_error",
|
|
139
|
+
message: "Claude Code process crashed (exit code 1). This usually means authentication expired. Run 'claude login' in your terminal to re-authenticate, then restart the proxy."
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: 502,
|
|
145
|
+
type: "api_error",
|
|
146
|
+
message: `Claude Code process exited unexpectedly (code ${code}). Check proxy logs for details. If this persists, try 'claude login' to refresh authentication.`
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Timeout
|
|
151
|
+
if (lower.includes("timeout") || lower.includes("timed out")) {
|
|
152
|
+
return {
|
|
153
|
+
status: 504,
|
|
154
|
+
type: "timeout_error",
|
|
155
|
+
message: "Request timed out. The operation may have been too complex. Try a simpler request."
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Server errors from Anthropic
|
|
160
|
+
if (lower.includes("500") || lower.includes("server error") || lower.includes("internal error")) {
|
|
161
|
+
return {
|
|
162
|
+
status: 502,
|
|
163
|
+
type: "api_error",
|
|
164
|
+
message: "Claude API returned a server error. This is usually temporary — try again in a moment."
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Overloaded
|
|
169
|
+
if (lower.includes("503") || lower.includes("overloaded")) {
|
|
170
|
+
return {
|
|
171
|
+
status: 503,
|
|
172
|
+
type: "overloaded_error",
|
|
173
|
+
message: "Claude is temporarily overloaded. Try again in a few seconds."
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Default
|
|
178
|
+
return {
|
|
179
|
+
status: 500,
|
|
180
|
+
type: "api_error",
|
|
181
|
+
message: errMsg || "Unknown error"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Block SDK built-in tools so Claude only uses MCP tools (which have correct param names)
|
|
186
|
+
const BLOCKED_BUILTIN_TOOLS = [
|
|
187
|
+
"Read", "Write", "Edit", "MultiEdit",
|
|
188
|
+
"Bash", "Glob", "Grep", "NotebookEdit",
|
|
189
|
+
"WebFetch", "WebSearch", "TodoWrite"
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
// Claude Code SDK tools that have NO equivalent in OpenCode.
|
|
193
|
+
// Only block these — everything else either has an OpenCode equivalent
|
|
194
|
+
// or is handled by OpenCode's own tool system.
|
|
195
|
+
//
|
|
196
|
+
// Tools where OpenCode has an equivalent but with a DIFFERENT name/schema
|
|
197
|
+
// are blocked so Claude uses OpenCode's version instead of the SDK's.
|
|
198
|
+
// See schema-incompatible section below.
|
|
199
|
+
//
|
|
200
|
+
// These truly have NO OpenCode equivalent (BLOCKED):
|
|
201
|
+
const CLAUDE_CODE_ONLY_TOOLS = [
|
|
202
|
+
"ToolSearch", // Claude Code deferred tool loading (internal mechanism)
|
|
203
|
+
"CronCreate", // Claude Code cron jobs
|
|
204
|
+
"CronDelete", // Claude Code cron jobs
|
|
205
|
+
"CronList", // Claude Code cron jobs
|
|
206
|
+
"EnterPlanMode", // Claude Code mode switching (OpenCode uses plan agent instead)
|
|
207
|
+
"ExitPlanMode", // Claude Code mode switching
|
|
208
|
+
"EnterWorktree", // Claude Code git worktree management
|
|
209
|
+
"ExitWorktree", // Claude Code git worktree management
|
|
210
|
+
"NotebookEdit", // Jupyter notebook editing
|
|
211
|
+
// Schema-incompatible: SDK tool name differs from OpenCode's.
|
|
212
|
+
// If Claude calls the SDK version, OpenCode won't recognize it.
|
|
213
|
+
// Block the SDK's so Claude only sees OpenCode's definitions.
|
|
214
|
+
"TodoWrite", // OpenCode: todowrite (requires 'priority' field)
|
|
215
|
+
"AskUserQuestion", // OpenCode: question
|
|
216
|
+
"Skill", // OpenCode: skill / skill_mcp / slashcommand
|
|
217
|
+
"Agent", // OpenCode: delegate_task / task
|
|
218
|
+
"TaskOutput", // OpenCode: background_output
|
|
219
|
+
"TaskStop", // OpenCode: background_cancel
|
|
220
|
+
"WebSearch", // OpenCode: websearch_web_search_exa
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
const MCP_SERVER_NAME = "opencode"
|
|
224
|
+
|
|
225
|
+
const ALLOWED_MCP_TOOLS = [
|
|
226
|
+
`mcp__${MCP_SERVER_NAME}__read`,
|
|
227
|
+
`mcp__${MCP_SERVER_NAME}__write`,
|
|
228
|
+
`mcp__${MCP_SERVER_NAME}__edit`,
|
|
229
|
+
`mcp__${MCP_SERVER_NAME}__bash`,
|
|
230
|
+
`mcp__${MCP_SERVER_NAME}__glob`,
|
|
231
|
+
`mcp__${MCP_SERVER_NAME}__grep`
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
function resolveClaudeExecutable(): string {
|
|
235
|
+
// 1. Try the SDK's bundled cli.js (same dir as this module's SDK)
|
|
236
|
+
try {
|
|
237
|
+
const sdkPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))
|
|
238
|
+
const sdkCliJs = join(dirname(sdkPath), "cli.js")
|
|
239
|
+
if (existsSync(sdkCliJs)) return sdkCliJs
|
|
240
|
+
} catch {}
|
|
241
|
+
|
|
242
|
+
// 2. Try the system-installed claude binary
|
|
243
|
+
try {
|
|
244
|
+
const claudePath = execSync("which claude", { encoding: "utf-8" }).trim()
|
|
245
|
+
if (claudePath && existsSync(claudePath)) return claudePath
|
|
246
|
+
} catch {}
|
|
247
|
+
|
|
248
|
+
throw new Error("Could not find Claude Code executable. Install via: npm install -g @anthropic-ai/claude-code")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const claudeExecutable = resolveClaudeExecutable()
|
|
8
252
|
|
|
9
253
|
function mapModelToClaudeModel(model: string): "sonnet" | "opus" | "haiku" {
|
|
10
254
|
if (model.includes("opus")) return "opus"
|
|
@@ -12,6 +256,11 @@ function mapModelToClaudeModel(model: string): "sonnet" | "opus" | "haiku" {
|
|
|
12
256
|
return "sonnet"
|
|
13
257
|
}
|
|
14
258
|
|
|
259
|
+
function isClosedControllerError(error: unknown): boolean {
|
|
260
|
+
if (!(error instanceof Error)) return false
|
|
261
|
+
return error.message.includes("Controller is already closed")
|
|
262
|
+
}
|
|
263
|
+
|
|
15
264
|
export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
16
265
|
const finalConfig = { ...DEFAULT_PROXY_CONFIG, ...config }
|
|
17
266
|
const app = new Hono()
|
|
@@ -28,25 +277,131 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
28
277
|
})
|
|
29
278
|
})
|
|
30
279
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
280
|
+
// --- Concurrency Control ---
|
|
281
|
+
// Each request spawns an SDK subprocess (cli.js, ~11MB). Spawning multiple
|
|
282
|
+
// simultaneously can crash the process. Serialize SDK queries with a queue.
|
|
283
|
+
const MAX_CONCURRENT_SESSIONS = parseInt(process.env.CLAUDE_PROXY_MAX_CONCURRENT || "10", 10)
|
|
284
|
+
let activeSessions = 0
|
|
285
|
+
const sessionQueue: Array<{ resolve: () => void }> = []
|
|
286
|
+
|
|
287
|
+
async function acquireSession(): Promise<void> {
|
|
288
|
+
if (activeSessions < MAX_CONCURRENT_SESSIONS) {
|
|
289
|
+
activeSessions++
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
// Wait for a slot
|
|
293
|
+
return new Promise<void>((resolve) => {
|
|
294
|
+
sessionQueue.push({ resolve })
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function releaseSession(): void {
|
|
299
|
+
activeSessions--
|
|
300
|
+
const next = sessionQueue.shift()
|
|
301
|
+
if (next) {
|
|
302
|
+
activeSessions++
|
|
303
|
+
next.resolve()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const handleMessages = async (
|
|
308
|
+
c: Context,
|
|
309
|
+
requestMeta: { requestId: string; endpoint: string; queueEnteredAt: number; queueStartedAt: number }
|
|
310
|
+
) => {
|
|
311
|
+
const requestStartAt = Date.now()
|
|
312
|
+
|
|
313
|
+
return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => {
|
|
314
|
+
try {
|
|
315
|
+
const body = await c.req.json()
|
|
316
|
+
const model = mapModelToClaudeModel(body.model || "sonnet")
|
|
317
|
+
const stream = body.stream ?? true
|
|
318
|
+
const workingDirectory = process.env.CLAUDE_PROXY_WORKDIR || process.cwd()
|
|
36
319
|
|
|
37
|
-
|
|
320
|
+
// Strip env vars that cause SDK subprocess to load unwanted plugins/features
|
|
321
|
+
const { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, ...cleanEnv } = process.env
|
|
322
|
+
|
|
323
|
+
// Session resume: look up cached Claude SDK session
|
|
324
|
+
const opencodeSessionId = c.req.header("x-opencode-session")
|
|
325
|
+
const cachedSession = lookupSession(opencodeSessionId, body.messages || [])
|
|
326
|
+
const resumeSessionId = cachedSession?.claudeSessionId
|
|
327
|
+
const isResume = Boolean(resumeSessionId)
|
|
328
|
+
|
|
329
|
+
// Debug: log request details
|
|
330
|
+
const msgSummary = body.messages?.map((m: any) => {
|
|
331
|
+
const contentTypes = Array.isArray(m.content)
|
|
332
|
+
? m.content.map((b: any) => b.type).join(",")
|
|
333
|
+
: "string"
|
|
334
|
+
return `${m.role}[${contentTypes}]`
|
|
335
|
+
}).join(" → ")
|
|
336
|
+
console.error(`[PROXY] ${requestMeta.requestId} model=${model} stream=${stream} tools=${body.tools?.length ?? 0} resume=${isResume} active=${activeSessions}/${MAX_CONCURRENT_SESSIONS} msgs=${msgSummary}`)
|
|
337
|
+
|
|
338
|
+
claudeLog("request.received", {
|
|
339
|
+
model,
|
|
340
|
+
stream,
|
|
341
|
+
queueWaitMs: requestMeta.queueStartedAt - requestMeta.queueEnteredAt,
|
|
342
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
343
|
+
hasSystemPrompt: Boolean(body.system)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Build system context from the request's system prompt
|
|
347
|
+
let systemContext = ""
|
|
348
|
+
if (body.system) {
|
|
349
|
+
if (typeof body.system === "string") {
|
|
350
|
+
systemContext = body.system
|
|
351
|
+
} else if (Array.isArray(body.system)) {
|
|
352
|
+
systemContext = body.system
|
|
353
|
+
.filter((b: any) => b.type === "text" && b.text)
|
|
354
|
+
.map((b: any) => b.text)
|
|
355
|
+
.join("\n")
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Extract available agent types from the Task tool definition.
|
|
360
|
+
// Used for: 1) SDK agent definitions, 2) fuzzy matching in PreToolUse hook, 3) prompt hints
|
|
361
|
+
let validAgentNames: string[] = []
|
|
362
|
+
let sdkAgents: Record<string, any> = {}
|
|
363
|
+
if (Array.isArray(body.tools)) {
|
|
364
|
+
const taskTool = body.tools.find((t: any) => t.name === "task" || t.name === "Task")
|
|
365
|
+
if (taskTool?.description) {
|
|
366
|
+
// Build SDK agent definitions from the Task tool description.
|
|
367
|
+
// This makes the SDK's native Task handler recognize agent names
|
|
368
|
+
// from OpenCode (with or without oh-my-opencode).
|
|
369
|
+
sdkAgents = buildAgentDefinitions(taskTool.description, [...ALLOWED_MCP_TOOLS])
|
|
370
|
+
validAgentNames = Object.keys(sdkAgents)
|
|
371
|
+
|
|
372
|
+
if (process.env.CLAUDE_PROXY_DEBUG) {
|
|
373
|
+
claudeLog("debug.agents", { names: validAgentNames, count: validAgentNames.length })
|
|
374
|
+
}
|
|
375
|
+
if (validAgentNames.length > 0) {
|
|
376
|
+
systemContext += `\n\nIMPORTANT: When using the task/Task tool, the subagent_type parameter must be one of these exact values (case-sensitive, lowercase): ${validAgentNames.join(", ")}. Do NOT capitalize or modify these names.`
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
38
380
|
|
|
39
|
-
|
|
40
|
-
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
// When resuming, only send the last user message (SDK already has history)
|
|
384
|
+
const messagesToConvert = isResume
|
|
385
|
+
? getLastUserMessage(body.messages || [])
|
|
386
|
+
: body.messages
|
|
387
|
+
|
|
388
|
+
// Convert messages to a text prompt, preserving all content types
|
|
389
|
+
const conversationParts = messagesToConvert
|
|
390
|
+
?.map((m: { role: string; content: string | Array<{ type: string; text?: string; content?: string; tool_use_id?: string; name?: string; input?: unknown; id?: string }> }) => {
|
|
41
391
|
const role = m.role === "assistant" ? "Assistant" : "Human"
|
|
42
392
|
let content: string
|
|
43
393
|
if (typeof m.content === "string") {
|
|
44
394
|
content = m.content
|
|
45
395
|
} else if (Array.isArray(m.content)) {
|
|
46
396
|
content = m.content
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
397
|
+
.map((block: any) => {
|
|
398
|
+
if (block.type === "text" && block.text) return block.text
|
|
399
|
+
if (block.type === "tool_use") return `[Tool Use: ${block.name}(${JSON.stringify(block.input)})]`
|
|
400
|
+
if (block.type === "tool_result") return `[Tool Result for ${block.tool_use_id}: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}]`
|
|
401
|
+
return ""
|
|
402
|
+
})
|
|
403
|
+
.filter(Boolean)
|
|
404
|
+
.join("\n")
|
|
50
405
|
} else {
|
|
51
406
|
content = String(m.content)
|
|
52
407
|
}
|
|
@@ -54,136 +409,595 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
54
409
|
})
|
|
55
410
|
.join("\n\n") || ""
|
|
56
411
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
412
|
+
// --- Passthrough mode ---
|
|
413
|
+
// When enabled, ALL tool execution is forwarded to OpenCode instead of
|
|
414
|
+
// being handled internally. This enables multi-model agent delegation
|
|
415
|
+
// (e.g., oracle on GPT-5.2, explore on Gemini via oh-my-opencode).
|
|
416
|
+
const passthrough = Boolean(process.env.CLAUDE_PROXY_PASSTHROUGH)
|
|
417
|
+
const capturedToolUses: Array<{ id: string; name: string; input: any }> = []
|
|
418
|
+
|
|
419
|
+
// In passthrough mode, register OpenCode's tools as MCP tools so Claude
|
|
420
|
+
// can actually call them (not just see them as text descriptions).
|
|
421
|
+
let passthroughMcp: ReturnType<typeof createPassthroughMcpServer> | undefined
|
|
422
|
+
if (passthrough && Array.isArray(body.tools) && body.tools.length > 0) {
|
|
423
|
+
passthroughMcp = createPassthroughMcpServer(body.tools)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
// In passthrough mode: block ALL tools, capture them for forwarding
|
|
429
|
+
// In normal mode: only fix agent names on Task tool
|
|
430
|
+
const sdkHooks = passthrough
|
|
431
|
+
? {
|
|
432
|
+
PreToolUse: [{
|
|
433
|
+
matcher: "", // Match ALL tools
|
|
434
|
+
hooks: [async (input: any) => {
|
|
435
|
+
capturedToolUses.push({
|
|
436
|
+
id: input.tool_use_id,
|
|
437
|
+
name: stripMcpPrefix(input.tool_name),
|
|
438
|
+
input: input.tool_input,
|
|
439
|
+
})
|
|
440
|
+
return {
|
|
441
|
+
decision: "block" as const,
|
|
442
|
+
reason: "Forwarding to client for execution",
|
|
443
|
+
}
|
|
444
|
+
}],
|
|
445
|
+
}],
|
|
446
|
+
}
|
|
447
|
+
: validAgentNames.length > 0
|
|
448
|
+
? {
|
|
449
|
+
PreToolUse: [{
|
|
450
|
+
matcher: "Task",
|
|
451
|
+
hooks: [async (input: any) => ({
|
|
452
|
+
hookSpecificOutput: {
|
|
453
|
+
hookEventName: "PreToolUse" as const,
|
|
454
|
+
updatedInput: {
|
|
455
|
+
...input.tool_input,
|
|
456
|
+
subagent_type: fuzzyMatchAgentName(
|
|
457
|
+
String(input.tool_input?.subagent_type || ""),
|
|
458
|
+
validAgentNames
|
|
459
|
+
),
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
})],
|
|
463
|
+
}],
|
|
464
|
+
}
|
|
465
|
+
: undefined
|
|
466
|
+
|
|
467
|
+
// Combine system context with conversation
|
|
468
|
+
const prompt = systemContext
|
|
469
|
+
? `${systemContext}\n\n${conversationParts}`
|
|
470
|
+
: conversationParts
|
|
471
|
+
|
|
472
|
+
if (!stream) {
|
|
473
|
+
const contentBlocks: Array<Record<string, unknown>> = []
|
|
474
|
+
let assistantMessages = 0
|
|
475
|
+
const upstreamStartAt = Date.now()
|
|
476
|
+
let firstChunkAt: number | undefined
|
|
477
|
+
let currentSessionId: string | undefined
|
|
478
|
+
|
|
479
|
+
claudeLog("upstream.start", { mode: "non_stream", model })
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const response = query({
|
|
483
|
+
prompt,
|
|
484
|
+
options: {
|
|
485
|
+
maxTurns: passthrough ? 1 : 100,
|
|
486
|
+
cwd: workingDirectory,
|
|
487
|
+
model,
|
|
488
|
+
pathToClaudeCodeExecutable: claudeExecutable,
|
|
489
|
+
permissionMode: "bypassPermissions",
|
|
490
|
+
allowDangerouslySkipPermissions: true,
|
|
491
|
+
// In passthrough mode: block ALL SDK built-in tools, use OpenCode's via MCP
|
|
492
|
+
// In normal mode: block built-ins, use our own MCP replacements
|
|
493
|
+
...(passthrough
|
|
494
|
+
? {
|
|
495
|
+
disallowedTools: [...BLOCKED_BUILTIN_TOOLS, ...CLAUDE_CODE_ONLY_TOOLS],
|
|
496
|
+
...(passthroughMcp ? {
|
|
497
|
+
allowedTools: passthroughMcp.toolNames,
|
|
498
|
+
mcpServers: { [PASSTHROUGH_MCP_NAME]: passthroughMcp.server },
|
|
499
|
+
} : {}),
|
|
500
|
+
}
|
|
501
|
+
: {
|
|
502
|
+
disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
|
|
503
|
+
allowedTools: [...ALLOWED_MCP_TOOLS],
|
|
504
|
+
mcpServers: { [MCP_SERVER_NAME]: opencodeMcpServer },
|
|
505
|
+
}),
|
|
506
|
+
plugins: [],
|
|
507
|
+
env: { ...cleanEnv, ENABLE_TOOL_SEARCH: "false" },
|
|
508
|
+
...(Object.keys(sdkAgents).length > 0 ? { agents: sdkAgents } : {}),
|
|
509
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
510
|
+
...(sdkHooks ? { hooks: sdkHooks } : {}),
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
for await (const message of response) {
|
|
515
|
+
// Capture session ID from SDK messages
|
|
516
|
+
if ((message as any).session_id) {
|
|
517
|
+
currentSessionId = (message as any).session_id
|
|
518
|
+
}
|
|
519
|
+
if (message.type === "assistant") {
|
|
520
|
+
assistantMessages += 1
|
|
521
|
+
if (!firstChunkAt) {
|
|
522
|
+
firstChunkAt = Date.now()
|
|
523
|
+
claudeLog("upstream.first_chunk", {
|
|
524
|
+
mode: "non_stream",
|
|
525
|
+
model,
|
|
526
|
+
ttfbMs: firstChunkAt - upstreamStartAt
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Preserve ALL content blocks (text, tool_use, thinking, etc.)
|
|
531
|
+
for (const block of message.message.content) {
|
|
532
|
+
const b = block as Record<string, unknown>
|
|
533
|
+
// In passthrough mode, strip MCP prefix from tool names
|
|
534
|
+
if (passthrough && b.type === "tool_use" && typeof b.name === "string") {
|
|
535
|
+
b.name = stripMcpPrefix(b.name as string)
|
|
536
|
+
}
|
|
537
|
+
contentBlocks.push(b)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
claudeLog("upstream.completed", {
|
|
543
|
+
mode: "non_stream",
|
|
544
|
+
model,
|
|
545
|
+
assistantMessages,
|
|
546
|
+
durationMs: Date.now() - upstreamStartAt
|
|
547
|
+
})
|
|
548
|
+
} catch (error) {
|
|
549
|
+
claudeLog("upstream.failed", {
|
|
550
|
+
mode: "non_stream",
|
|
551
|
+
model,
|
|
552
|
+
durationMs: Date.now() - upstreamStartAt,
|
|
553
|
+
error: error instanceof Error ? error.message : String(error)
|
|
554
|
+
})
|
|
555
|
+
throw error
|
|
556
|
+
}
|
|
63
557
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
558
|
+
// In passthrough mode, add captured tool_use blocks from the hook
|
|
559
|
+
// (the SDK may not include them in content after blocking)
|
|
560
|
+
if (passthrough && capturedToolUses.length > 0) {
|
|
561
|
+
for (const tu of capturedToolUses) {
|
|
562
|
+
// Only add if not already in contentBlocks
|
|
563
|
+
if (!contentBlocks.some((b) => b.type === "tool_use" && (b as any).id === tu.id)) {
|
|
564
|
+
contentBlocks.push({
|
|
565
|
+
type: "tool_use",
|
|
566
|
+
id: tu.id,
|
|
567
|
+
name: tu.name,
|
|
568
|
+
input: tu.input,
|
|
569
|
+
})
|
|
69
570
|
}
|
|
70
571
|
}
|
|
71
572
|
}
|
|
573
|
+
|
|
574
|
+
// Determine stop_reason based on content: tool_use if any tool blocks, else end_turn
|
|
575
|
+
const hasToolUse = contentBlocks.some((b) => b.type === "tool_use")
|
|
576
|
+
const stopReason = hasToolUse ? "tool_use" : "end_turn"
|
|
577
|
+
|
|
578
|
+
// If no content at all, add a fallback text block
|
|
579
|
+
if (contentBlocks.length === 0) {
|
|
580
|
+
contentBlocks.push({
|
|
581
|
+
type: "text",
|
|
582
|
+
text: "I can help with that. Could you provide more details about what you'd like me to do?"
|
|
583
|
+
})
|
|
584
|
+
claudeLog("response.fallback_used", { mode: "non_stream", reason: "no_content_blocks" })
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
claudeLog("response.completed", {
|
|
588
|
+
mode: "non_stream",
|
|
589
|
+
model,
|
|
590
|
+
durationMs: Date.now() - requestStartAt,
|
|
591
|
+
contentBlocks: contentBlocks.length,
|
|
592
|
+
hasToolUse
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// Store session for future resume
|
|
596
|
+
if (currentSessionId) {
|
|
597
|
+
storeSession(opencodeSessionId, body.messages || [], currentSessionId)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const responseSessionId = currentSessionId || resumeSessionId || `session_${Date.now()}`
|
|
601
|
+
|
|
602
|
+
return new Response(JSON.stringify({
|
|
603
|
+
id: `msg_${Date.now()}`,
|
|
604
|
+
type: "message",
|
|
605
|
+
role: "assistant",
|
|
606
|
+
content: contentBlocks,
|
|
607
|
+
model: body.model,
|
|
608
|
+
stop_reason: stopReason,
|
|
609
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
610
|
+
}), {
|
|
611
|
+
headers: {
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
"X-Claude-Session-ID": responseSessionId,
|
|
614
|
+
}
|
|
615
|
+
})
|
|
72
616
|
}
|
|
73
617
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
618
|
+
const encoder = new TextEncoder()
|
|
619
|
+
const readable = new ReadableStream({
|
|
620
|
+
async start(controller) {
|
|
621
|
+
const upstreamStartAt = Date.now()
|
|
622
|
+
let firstChunkAt: number | undefined
|
|
623
|
+
let heartbeatCount = 0
|
|
624
|
+
let streamEventsSeen = 0
|
|
625
|
+
let eventsForwarded = 0
|
|
626
|
+
let textEventsForwarded = 0
|
|
627
|
+
let bytesSent = 0
|
|
628
|
+
let streamClosed = false
|
|
84
629
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
630
|
+
claudeLog("upstream.start", { mode: "stream", model })
|
|
631
|
+
|
|
632
|
+
const safeEnqueue = (payload: Uint8Array, source: string): boolean => {
|
|
633
|
+
if (streamClosed) return false
|
|
634
|
+
try {
|
|
635
|
+
controller.enqueue(payload)
|
|
636
|
+
bytesSent += payload.byteLength
|
|
637
|
+
return true
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (isClosedControllerError(error)) {
|
|
640
|
+
streamClosed = true
|
|
641
|
+
claudeLog("stream.client_closed", { source, streamEventsSeen, eventsForwarded })
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
claudeLog("stream.enqueue_failed", {
|
|
646
|
+
source,
|
|
647
|
+
error: error instanceof Error ? error.message : String(error)
|
|
648
|
+
})
|
|
649
|
+
throw error
|
|
99
650
|
}
|
|
100
|
-
}
|
|
651
|
+
}
|
|
101
652
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
653
|
+
try {
|
|
654
|
+
let currentSessionId: string | undefined
|
|
655
|
+
const response = query({
|
|
656
|
+
prompt,
|
|
657
|
+
options: {
|
|
658
|
+
maxTurns: passthrough ? 1 : 100,
|
|
659
|
+
cwd: workingDirectory,
|
|
660
|
+
model,
|
|
661
|
+
pathToClaudeCodeExecutable: claudeExecutable,
|
|
662
|
+
includePartialMessages: true,
|
|
663
|
+
permissionMode: "bypassPermissions",
|
|
664
|
+
allowDangerouslySkipPermissions: true,
|
|
665
|
+
...(passthrough
|
|
666
|
+
? {
|
|
667
|
+
disallowedTools: [...BLOCKED_BUILTIN_TOOLS, ...CLAUDE_CODE_ONLY_TOOLS],
|
|
668
|
+
...(passthroughMcp ? {
|
|
669
|
+
allowedTools: passthroughMcp.toolNames,
|
|
670
|
+
mcpServers: { [PASSTHROUGH_MCP_NAME]: passthroughMcp.server },
|
|
671
|
+
} : {}),
|
|
672
|
+
}
|
|
673
|
+
: {
|
|
674
|
+
disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
|
|
675
|
+
allowedTools: [...ALLOWED_MCP_TOOLS],
|
|
676
|
+
mcpServers: { [MCP_SERVER_NAME]: opencodeMcpServer },
|
|
677
|
+
}),
|
|
678
|
+
plugins: [],
|
|
679
|
+
env: { ...cleanEnv, ENABLE_TOOL_SEARCH: "false" },
|
|
680
|
+
...(Object.keys(sdkAgents).length > 0 ? { agents: sdkAgents } : {}),
|
|
681
|
+
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
682
|
+
...(sdkHooks ? { hooks: sdkHooks } : {}),
|
|
683
|
+
}
|
|
684
|
+
})
|
|
107
685
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
686
|
+
const heartbeat = setInterval(() => {
|
|
687
|
+
heartbeatCount += 1
|
|
688
|
+
try {
|
|
689
|
+
const payload = encoder.encode(`: ping\n\n`)
|
|
690
|
+
if (!safeEnqueue(payload, "heartbeat")) {
|
|
691
|
+
clearInterval(heartbeat)
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (heartbeatCount % 5 === 0) {
|
|
695
|
+
claudeLog("stream.heartbeat", { count: heartbeatCount })
|
|
696
|
+
}
|
|
697
|
+
} catch (error) {
|
|
698
|
+
claudeLog("stream.heartbeat_failed", {
|
|
699
|
+
count: heartbeatCount,
|
|
700
|
+
error: error instanceof Error ? error.message : String(error)
|
|
701
|
+
})
|
|
702
|
+
clearInterval(heartbeat)
|
|
703
|
+
}
|
|
704
|
+
}, 15_000)
|
|
705
|
+
|
|
706
|
+
const skipBlockIndices = new Set<number>()
|
|
707
|
+
let messageStartEmitted = false // Track if we've sent a message_start to the client
|
|
112
708
|
|
|
113
|
-
const heartbeat = setInterval(() => {
|
|
114
709
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
710
|
+
for await (const message of response) {
|
|
711
|
+
if (streamClosed) {
|
|
712
|
+
break
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Capture session ID from any SDK message
|
|
716
|
+
if ((message as any).session_id) {
|
|
717
|
+
currentSessionId = (message as any).session_id
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (message.type === "stream_event") {
|
|
721
|
+
streamEventsSeen += 1
|
|
722
|
+
if (!firstChunkAt) {
|
|
723
|
+
firstChunkAt = Date.now()
|
|
724
|
+
claudeLog("upstream.first_chunk", {
|
|
725
|
+
mode: "stream",
|
|
726
|
+
model,
|
|
727
|
+
ttfbMs: firstChunkAt - upstreamStartAt
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const event = message.event
|
|
732
|
+
const eventType = (event as any).type
|
|
733
|
+
const eventIndex = (event as any).index as number | undefined
|
|
734
|
+
|
|
735
|
+
// Track MCP tool blocks (mcp__opencode__*) — these are internal tools
|
|
736
|
+
// that the SDK executes. Don't forward them to OpenCode.
|
|
737
|
+
if (eventType === "message_start") {
|
|
738
|
+
skipBlockIndices.clear()
|
|
739
|
+
// Only emit the first message_start — subsequent ones are internal SDK turns
|
|
740
|
+
if (messageStartEmitted) {
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
messageStartEmitted = true
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Skip intermediate message_stop events (SDK will start another turn)
|
|
747
|
+
// Only emit message_stop when the final message ends
|
|
748
|
+
if (eventType === "message_stop") {
|
|
749
|
+
// Peek: if there are more events coming, skip this message_stop
|
|
750
|
+
// We handle this by only emitting message_stop at the very end (after the loop)
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (eventType === "content_block_start") {
|
|
755
|
+
const block = (event as any).content_block
|
|
756
|
+
if (block?.type === "tool_use" && typeof block.name === "string") {
|
|
757
|
+
if (passthrough && block.name.startsWith(PASSTHROUGH_MCP_PREFIX)) {
|
|
758
|
+
// Passthrough mode: strip prefix and forward to OpenCode
|
|
759
|
+
block.name = stripMcpPrefix(block.name)
|
|
760
|
+
} else if (block.name.startsWith("mcp__")) {
|
|
761
|
+
// Internal mode: skip all MCP tool blocks (internal execution)
|
|
762
|
+
if (eventIndex !== undefined) skipBlockIndices.add(eventIndex)
|
|
763
|
+
continue
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Skip deltas and stops for MCP tool blocks
|
|
769
|
+
if (eventIndex !== undefined && skipBlockIndices.has(eventIndex)) {
|
|
770
|
+
continue
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Skip intermediate message_delta with stop_reason: tool_use
|
|
774
|
+
// (SDK is about to execute MCP tools and continue)
|
|
775
|
+
if (eventType === "message_delta") {
|
|
776
|
+
const stopReason = (event as any).delta?.stop_reason
|
|
777
|
+
if (stopReason === "tool_use" && skipBlockIndices.size > 0) {
|
|
778
|
+
// All tool_use blocks in this turn were MCP — skip this delta
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Forward all other events (text, non-MCP tool_use like Task, message events)
|
|
784
|
+
const payload = encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
785
|
+
if (!safeEnqueue(payload, `stream_event:${eventType}`)) {
|
|
786
|
+
break
|
|
787
|
+
}
|
|
788
|
+
eventsForwarded += 1
|
|
789
|
+
|
|
790
|
+
if (eventType === "content_block_delta") {
|
|
791
|
+
const delta = (event as any).delta
|
|
792
|
+
if (delta?.type === "text_delta") {
|
|
793
|
+
textEventsForwarded += 1
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} finally {
|
|
117
799
|
clearInterval(heartbeat)
|
|
118
800
|
}
|
|
119
|
-
}, 15_000)
|
|
120
801
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
802
|
+
claudeLog("upstream.completed", {
|
|
803
|
+
mode: "stream",
|
|
804
|
+
model,
|
|
805
|
+
durationMs: Date.now() - upstreamStartAt,
|
|
806
|
+
streamEventsSeen,
|
|
807
|
+
eventsForwarded,
|
|
808
|
+
textEventsForwarded
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// Store session for future resume
|
|
812
|
+
if (currentSessionId) {
|
|
813
|
+
storeSession(opencodeSessionId, body.messages || [], currentSessionId)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (!streamClosed) {
|
|
817
|
+
// In passthrough mode, emit captured tool_use blocks as stream events
|
|
818
|
+
if (passthrough && capturedToolUses.length > 0 && messageStartEmitted) {
|
|
819
|
+
for (let i = 0; i < capturedToolUses.length; i++) {
|
|
820
|
+
const tu = capturedToolUses[i]!
|
|
821
|
+
const blockIndex = eventsForwarded + i
|
|
822
|
+
|
|
823
|
+
// content_block_start
|
|
824
|
+
safeEnqueue(encoder.encode(
|
|
825
|
+
`event: content_block_start\ndata: ${JSON.stringify({
|
|
826
|
+
type: "content_block_start",
|
|
827
|
+
index: blockIndex,
|
|
828
|
+
content_block: { type: "tool_use", id: tu.id, name: tu.name, input: {} }
|
|
829
|
+
})}\n\n`
|
|
830
|
+
), "passthrough_tool_block_start")
|
|
831
|
+
|
|
832
|
+
// input_json_delta with the full input
|
|
833
|
+
safeEnqueue(encoder.encode(
|
|
834
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
127
835
|
type: "content_block_delta",
|
|
128
|
-
index:
|
|
129
|
-
delta: { type: "
|
|
130
|
-
})}\n\n`
|
|
131
|
-
|
|
836
|
+
index: blockIndex,
|
|
837
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(tu.input) }
|
|
838
|
+
})}\n\n`
|
|
839
|
+
), "passthrough_tool_input")
|
|
840
|
+
|
|
841
|
+
// content_block_stop
|
|
842
|
+
safeEnqueue(encoder.encode(
|
|
843
|
+
`event: content_block_stop\ndata: ${JSON.stringify({
|
|
844
|
+
type: "content_block_stop",
|
|
845
|
+
index: blockIndex
|
|
846
|
+
})}\n\n`
|
|
847
|
+
), "passthrough_tool_block_stop")
|
|
132
848
|
}
|
|
849
|
+
|
|
850
|
+
// Emit message_delta with stop_reason: "tool_use"
|
|
851
|
+
safeEnqueue(encoder.encode(
|
|
852
|
+
`event: message_delta\ndata: ${JSON.stringify({
|
|
853
|
+
type: "message_delta",
|
|
854
|
+
delta: { stop_reason: "tool_use", stop_sequence: null },
|
|
855
|
+
usage: { output_tokens: 0 }
|
|
856
|
+
})}\n\n`
|
|
857
|
+
), "passthrough_message_delta")
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Emit the final message_stop (we skipped all intermediate ones)
|
|
861
|
+
if (messageStartEmitted) {
|
|
862
|
+
safeEnqueue(encoder.encode(`event: message_stop\ndata: {"type":"message_stop"}\n\n`), "final_message_stop")
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
try { controller.close() } catch {}
|
|
866
|
+
streamClosed = true
|
|
867
|
+
|
|
868
|
+
claudeLog("stream.ended", {
|
|
869
|
+
model,
|
|
870
|
+
streamEventsSeen,
|
|
871
|
+
eventsForwarded,
|
|
872
|
+
textEventsForwarded,
|
|
873
|
+
bytesSent,
|
|
874
|
+
durationMs: Date.now() - requestStartAt
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
claudeLog("response.completed", {
|
|
878
|
+
mode: "stream",
|
|
879
|
+
model,
|
|
880
|
+
durationMs: Date.now() - requestStartAt,
|
|
881
|
+
streamEventsSeen,
|
|
882
|
+
eventsForwarded,
|
|
883
|
+
textEventsForwarded
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
if (textEventsForwarded === 0) {
|
|
887
|
+
claudeLog("response.empty_stream", {
|
|
888
|
+
model,
|
|
889
|
+
streamEventsSeen,
|
|
890
|
+
eventsForwarded,
|
|
891
|
+
reason: "no_text_deltas_forwarded"
|
|
892
|
+
})
|
|
133
893
|
}
|
|
134
894
|
}
|
|
135
|
-
}
|
|
136
|
-
|
|
895
|
+
} catch (error) {
|
|
896
|
+
if (isClosedControllerError(error)) {
|
|
897
|
+
streamClosed = true
|
|
898
|
+
claudeLog("stream.client_closed", {
|
|
899
|
+
source: "stream_catch",
|
|
900
|
+
streamEventsSeen,
|
|
901
|
+
eventsForwarded,
|
|
902
|
+
textEventsForwarded,
|
|
903
|
+
durationMs: Date.now() - requestStartAt
|
|
904
|
+
})
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
claudeLog("upstream.failed", {
|
|
909
|
+
mode: "stream",
|
|
910
|
+
model,
|
|
911
|
+
durationMs: Date.now() - upstreamStartAt,
|
|
912
|
+
streamEventsSeen,
|
|
913
|
+
textEventsForwarded,
|
|
914
|
+
error: error instanceof Error ? error.message : String(error)
|
|
915
|
+
})
|
|
916
|
+
const streamErr = classifyError(error instanceof Error ? error.message : String(error))
|
|
917
|
+
claudeLog("proxy.anthropic.error", { error: error instanceof Error ? error.message : String(error), classified: streamErr.type })
|
|
918
|
+
safeEnqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({
|
|
919
|
+
type: "error",
|
|
920
|
+
error: { type: streamErr.type, message: streamErr.message }
|
|
921
|
+
})}\n\n`), "error_event")
|
|
922
|
+
if (!streamClosed) {
|
|
923
|
+
try { controller.close() } catch {}
|
|
924
|
+
streamClosed = true
|
|
925
|
+
}
|
|
137
926
|
}
|
|
927
|
+
}
|
|
928
|
+
})
|
|
138
929
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
930
|
+
const streamSessionId = resumeSessionId || `session_${Date.now()}`
|
|
931
|
+
return new Response(readable, {
|
|
932
|
+
headers: {
|
|
933
|
+
"Content-Type": "text/event-stream",
|
|
934
|
+
"Cache-Control": "no-cache",
|
|
935
|
+
Connection: "keep-alive",
|
|
936
|
+
"X-Claude-Session-ID": streamSessionId
|
|
937
|
+
}
|
|
938
|
+
})
|
|
939
|
+
} catch (error) {
|
|
940
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
941
|
+
claudeLog("error.unhandled", {
|
|
942
|
+
durationMs: Date.now() - requestStartAt,
|
|
943
|
+
error: errMsg
|
|
944
|
+
})
|
|
143
945
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
delta: { stop_reason: "end_turn" },
|
|
147
|
-
usage: { output_tokens: 0 }
|
|
148
|
-
})}\n\n`))
|
|
946
|
+
// Detect specific error types and return helpful messages
|
|
947
|
+
const classified = classifyError(errMsg)
|
|
149
948
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
949
|
+
claudeLog("proxy.error", { error: errMsg, classified: classified.type })
|
|
950
|
+
return new Response(
|
|
951
|
+
JSON.stringify({ type: "error", error: { type: classified.type, message: classified.message } }),
|
|
952
|
+
{ status: classified.status, headers: { "Content-Type": "application/json" } }
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
}
|
|
153
957
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
})}\n\n`))
|
|
161
|
-
controller.close()
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
})
|
|
958
|
+
app.post("/v1/messages", async (c) => {
|
|
959
|
+
const requestId = c.req.header("x-request-id") || randomUUID()
|
|
960
|
+
const startedAt = Date.now()
|
|
961
|
+
claudeLog("request.enter", { requestId, endpoint: "/v1/messages" })
|
|
962
|
+
return handleMessages(c, { requestId, endpoint: "/v1/messages", queueEnteredAt: startedAt, queueStartedAt: startedAt })
|
|
963
|
+
})
|
|
165
964
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
965
|
+
app.post("/messages", async (c) => {
|
|
966
|
+
const requestId = c.req.header("x-request-id") || randomUUID()
|
|
967
|
+
const startedAt = Date.now()
|
|
968
|
+
claudeLog("request.enter", { requestId, endpoint: "/messages" })
|
|
969
|
+
return handleMessages(c, { requestId, endpoint: "/messages", queueEnteredAt: startedAt, queueStartedAt: startedAt })
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
// Health check endpoint — verifies auth status
|
|
973
|
+
app.get("/health", (c) => {
|
|
974
|
+
try {
|
|
975
|
+
const authJson = execSync("claude auth status", { encoding: "utf-8", timeout: 5000 })
|
|
976
|
+
const auth = JSON.parse(authJson)
|
|
977
|
+
if (!auth.loggedIn) {
|
|
978
|
+
return c.json({
|
|
979
|
+
status: "unhealthy",
|
|
980
|
+
error: "Not logged in. Run: claude login",
|
|
981
|
+
auth: { loggedIn: false }
|
|
982
|
+
}, 503)
|
|
983
|
+
}
|
|
984
|
+
return c.json({
|
|
985
|
+
status: "healthy",
|
|
986
|
+
auth: {
|
|
987
|
+
loggedIn: true,
|
|
988
|
+
email: auth.email,
|
|
989
|
+
subscriptionType: auth.subscriptionType,
|
|
990
|
+
},
|
|
991
|
+
mode: process.env.CLAUDE_PROXY_PASSTHROUGH ? "passthrough" : "internal",
|
|
172
992
|
})
|
|
173
|
-
} catch
|
|
174
|
-
claudeLog("proxy.error", { error: error instanceof Error ? error.message : String(error) })
|
|
993
|
+
} catch {
|
|
175
994
|
return c.json({
|
|
176
|
-
|
|
177
|
-
error:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
}, 500)
|
|
995
|
+
status: "degraded",
|
|
996
|
+
error: "Could not verify auth status",
|
|
997
|
+
mode: process.env.CLAUDE_PROXY_PASSTHROUGH ? "passthrough" : "internal",
|
|
998
|
+
})
|
|
182
999
|
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
app.post("/v1/messages", handleMessages)
|
|
186
|
-
app.post("/messages", handleMessages)
|
|
1000
|
+
})
|
|
187
1001
|
|
|
188
1002
|
return { app, config: finalConfig }
|
|
189
1003
|
}
|
|
@@ -191,11 +1005,26 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
|
191
1005
|
export async function startProxyServer(config: Partial<ProxyConfig> = {}) {
|
|
192
1006
|
const { app, config: finalConfig } = createProxyServer(config)
|
|
193
1007
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
1008
|
+
let server
|
|
1009
|
+
try {
|
|
1010
|
+
server = Bun.serve({
|
|
1011
|
+
port: finalConfig.port,
|
|
1012
|
+
hostname: finalConfig.host,
|
|
1013
|
+
idleTimeout: finalConfig.idleTimeoutSeconds,
|
|
1014
|
+
fetch: app.fetch
|
|
1015
|
+
})
|
|
1016
|
+
} catch (error: unknown) {
|
|
1017
|
+
if (error instanceof Error && "code" in error && error.code === "EADDRINUSE") {
|
|
1018
|
+
console.error(`\nError: Port ${finalConfig.port} is already in use.`)
|
|
1019
|
+
console.error(`\nIs another instance of the proxy already running?`)
|
|
1020
|
+
console.error(` Check with: lsof -i :${finalConfig.port}`)
|
|
1021
|
+
console.error(` Kill it with: kill $(lsof -ti :${finalConfig.port})`)
|
|
1022
|
+
console.error(`\nOr use a different port:`)
|
|
1023
|
+
console.error(` CLAUDE_PROXY_PORT=4567 bun run proxy`)
|
|
1024
|
+
process.exit(1)
|
|
1025
|
+
}
|
|
1026
|
+
throw error
|
|
1027
|
+
}
|
|
199
1028
|
|
|
200
1029
|
console.log(`Claude Max Proxy (Anthropic API) running at http://${finalConfig.host}:${finalConfig.port}`)
|
|
201
1030
|
console.log(`\nTo use with OpenCode, run:`)
|