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