From 1507c416867014cb79f9afdbbb9859991128321a Mon Sep 17 00:00:00 2001 From: Endre Nagy Date: Tue, 31 Mar 2026 10:41:58 +0200 Subject: [PATCH 1/2] feat: add LiteLLM passthrough adapter with x-litellm-* header detection --- src/__tests__/passthrough-adapter.test.ts | 275 ++++++++++++++++++++++ src/proxy/adapters/detect.ts | 40 +++- src/proxy/adapters/passthrough.ts | 160 +++++++++++++ src/proxy/session/cache.ts | 40 +++- 4 files changed, 510 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/passthrough-adapter.test.ts create mode 100644 src/proxy/adapters/passthrough.ts diff --git a/src/__tests__/passthrough-adapter.test.ts b/src/__tests__/passthrough-adapter.test.ts new file mode 100644 index 0000000..3949a1a --- /dev/null +++ b/src/__tests__/passthrough-adapter.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for the passthrough proxy adapter. + */ +import { describe, it, expect } from "bun:test" +import { passthroughAdapter } from "../proxy/adapters/passthrough" +import { detectAdapter } from "../proxy/adapters/detect" + +describe("passthroughAdapter — identity", () => { + it("has name 'passthrough'", () => { + expect(passthroughAdapter.name).toBe("passthrough") + }) +}) + +describe("passthroughAdapter.getSessionId", () => { + it("returns session id from x-litellm-session-id header", () => { + const mockContext = { + req: { + header: (name: string) => { + if (name === "x-litellm-session-id") return "litellm-abc" + if (name === "x-opencode-session") return "opencode-xyz" + return undefined + }, + }, + } + expect(passthroughAdapter.getSessionId(mockContext as any)).toBe("litellm-abc") + }) + + it("returns undefined when no session header present", () => { + const mockContext = { + req: { + header: () => undefined, + }, + } + expect(passthroughAdapter.getSessionId(mockContext as any)).toBeUndefined() + }) +}) + +describe("passthroughAdapter.extractWorkingDirectory", () => { + it("extracts CWD from block with cwd attribute", () => { + const body = { + model: "claude-sonnet-4-5-20250514", + messages: [ + { + role: "user", + content: "test", + }, + ], + } + expect(passthroughAdapter.extractWorkingDirectory(body)).toBe("/home/user/project") + }) + + it("extracts CWD from simple prompt with cwd", () => { + const body = { + prompt: 'Some text cwd="/tmp/work" more text', + } + expect(passthroughAdapter.extractWorkingDirectory(body)).toBe("/tmp/work") + }) + + it("returns undefined when no CWD in prompt", () => { + expect(passthroughAdapter.extractWorkingDirectory({})).toBeUndefined() + }) + + it("returns undefined for request body without CWD info", () => { + const body = { + model: "claude-sonnet-4-5-20250514", + max_tokens: 32000, + messages: [{ role: "user", content: "Hello" }], + tools: [{ name: "bash", description: "Run a command" }], + } + expect(passthroughAdapter.extractWorkingDirectory(body)).toBeUndefined() + }) + + it("returns undefined even with system prompt present", () => { + const body = { + system: [{ type: "text", text: "You are Claude..." }], + messages: [{ role: "user", content: "Hello" }], + } + expect(passthroughAdapter.extractWorkingDirectory(body)).toBeUndefined() + }) +}) + +describe("passthroughAdapter.normalizeContent", () => { + it("normalizes string content", () => { + expect(passthroughAdapter.normalizeContent("hello world")).toBe("hello world") + }) + + it("normalizes array content to text", () => { + const content = [ + { type: "text", text: "hello" }, + { type: "text", text: " world" }, + ] + expect(passthroughAdapter.normalizeContent(content)).toBe("hello\n world") + }) + + it("normalizes tool_use blocks", () => { + const content = [ + { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, + ] + const result = passthroughAdapter.normalizeContent(content) + expect(result).toContain("tool_use") + expect(result).toContain("bash") + }) + + it("converts non-string/array content to string", () => { + expect(passthroughAdapter.normalizeContent(42 as any)).toBe("42") + }) +}) + +describe("passthroughAdapter tool configuration", () => { + it("getBlockedBuiltinTools returns empty array (passthrough mode)", () => { + const blocked = passthroughAdapter.getBlockedBuiltinTools() + expect(blocked).toHaveLength(0) + }) + + it("getAgentIncompatibleTools returns empty array (passthrough mode)", () => { + const incompatible = passthroughAdapter.getAgentIncompatibleTools() + expect(incompatible).toHaveLength(0) + }) + + it("getMcpServerName returns 'litellm'", () => { + expect(passthroughAdapter.getMcpServerName()).toBe("litellm") + }) + + it("getAllowedMcpTools returns exactly 6 tools", () => { + expect(passthroughAdapter.getAllowedMcpTools()).toHaveLength(6) + }) + + it("all tools have mcp__litellm__ prefix", () => { + for (const tool of passthroughAdapter.getAllowedMcpTools()) { + expect(tool).toStartWith("mcp__litellm__") + } + }) + + it("getAllowedMcpTools covers the standard set", () => { + const tools = passthroughAdapter.getAllowedMcpTools() + expect(tools).toContain("mcp__litellm__read") + expect(tools).toContain("mcp__litellm__write") + expect(tools).toContain("mcp__litellm__edit") + expect(tools).toContain("mcp__litellm__bash") + expect(tools).toContain("mcp__litellm__glob") + expect(tools).toContain("mcp__litellm__grep") + }) +}) + +describe("passthroughAdapter.buildSdkAgents", () => { + it("always returns empty object — passthrough manages subagents internally", () => { + const body = { + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "Hello" }], + } + expect(passthroughAdapter.buildSdkAgents!(body, ["mcp__litellm__read"])).toEqual({}) + }) + + it("returns empty even with Task-like tools in the body", () => { + const body = { + tools: [{ + name: "task", + description: "Available agent types:\n- oracle: research\n- coder: coding", + input_schema: { type: "object" }, + }], + } + expect(passthroughAdapter.buildSdkAgents!(body, [])).toEqual({}) + }) + + it("returns empty for empty body", () => { + expect(passthroughAdapter.buildSdkAgents!({}, [])).toEqual({}) + }) +}) + +describe("passthroughAdapter.buildSdkHooks", () => { + it("always returns undefined — no hook-based agent correction needed", () => { + const sdkAgents = { oracle: {}, explore: {} } + expect(passthroughAdapter.buildSdkHooks!({}, sdkAgents)).toBeUndefined() + }) + + it("returns undefined for empty agents", () => { + expect(passthroughAdapter.buildSdkHooks!({}, {})).toBeUndefined() + }) +}) + +describe("passthroughAdapter.buildSystemContextAddendum", () => { + it("always returns empty string — no extra context for passthrough", () => { + const sdkAgents = { oracle: {}, explore: {} } + expect(passthroughAdapter.buildSystemContextAddendum!({}, sdkAgents)).toBe("") + }) + + it("returns empty string for empty agents", () => { + expect(passthroughAdapter.buildSystemContextAddendum!({}, {})).toBe("") + }) +}) + +describe("passthroughAdapter.usesPassthrough", () => { + it("always returns true — passthrough requires passthrough mode", () => { + expect(passthroughAdapter.usesPassthrough!()).toBe(true) + }) +}) + +describe("detectAdapter — passthrough detection", () => { + it("detects passthrough by x-litellm-api-key header", () => { + const c = { + req: { + header: (name: string) => { + if (name === "x-litellm-api-key") return "sk-123456" + if (name === "user-agent") return "python-httpx/0.27.0" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("passthrough") + }) + + it("detects passthrough by x-litellm-model header", () => { + const c = { + req: { + header: (name: string) => { + if (name === "x-litellm-model") return "claude-sonnet-4-5" + if (name === "user-agent") return "python-httpx/0.27.0" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("passthrough") + }) + + it("detects passthrough by any x-litellm-* header (case insensitive)", () => { + const c = { + req: { + header: (name: string) => { + if (name.toLowerCase() === "x-litellm-custom") return "value" + if (name === "user-agent") return "python-httpx/0.27.0" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("passthrough") + }) + + it("falls back to opencode for generic python-httpx without litellm headers", () => { + const c = { + req: { + header: (name: string) => { + if (name === "user-agent") return "python-httpx/0.27.0" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("opencode") + }) + + it("still prefers Droid over passthrough when factory-cli User-Agent present", () => { + const c = { + req: { + header: (name: string) => { + if (name === "user-agent") return "factory-cli/1.0.0" + if (name === "x-litellm-api-key") return "sk-123456" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("droid") + }) + + it("still prefers Crush over passthrough when Charm-Crush User-Agent present", () => { + const c = { + req: { + header: (name: string) => { + if (name === "user-agent") return "Charm-Crush/0.1.0" + if (name === "x-litellm-api-key") return "sk-123456" + return undefined + }, + }, + } + expect(detectAdapter(c as any).name).toBe("crush") + }) +}) diff --git a/src/proxy/adapters/detect.ts b/src/proxy/adapters/detect.ts index 691e8b1..e10e9e0 100644 --- a/src/proxy/adapters/detect.ts +++ b/src/proxy/adapters/detect.ts @@ -10,6 +10,39 @@ import type { AgentAdapter } from "../adapter" import { openCodeAdapter } from "./opencode" import { droidAdapter } from "./droid" import { crushAdapter } from "./crush" +import { passthroughAdapter } from "./passthrough" + +/** + * Detect LiteLLM-style passthrough requests by checking for x-litellm-* headers. + * + * LiteLLM sends x-litellm-* headers when configured with a proxy provider. + * This is the most reliable detection method since LiteLLM's default + * User-Agent is generic (python-httpx). + * + * Note: This detects LiteLLM-style requests, but the adapter is generically + * named "passthrough" since it describes standard Anthropic API passthrough behavior. + */ +const LITELLM_HEADER_PREFIX = "x-litellm-" + +function hasLiteLLMHeaders(c: Context): boolean { + const headersToCheck = [ + "x-litellm-api-key", + "x-litellm-model", + "x-litellm-custom", + "x-litellm-organization", + "x-litellm-user", + "x-litellm-batch-write", + "x-litellm-success-callback", + "x-litellm-failure-callback", + "x-litellm-stream-options", + ] + for (const name of headersToCheck) { + if (c.req.header(name)) { + return true + } + } + return false +} /** * Detect which agent adapter to use based on request headers. @@ -17,7 +50,8 @@ import { crushAdapter } from "./crush" * Detection rules (evaluated in order): * 1. User-Agent starts with "factory-cli/" → Droid adapter * 2. User-Agent starts with "Charm-Crush/" → Crush adapter - * 3. Default → OpenCode adapter (backward compatible) + * 3. x-litellm-* headers present → Passthrough adapter + * 4. Default → OpenCode adapter (backward compatible) */ export function detectAdapter(c: Context): AgentAdapter { const userAgent = c.req.header("user-agent") || "" @@ -30,5 +64,9 @@ export function detectAdapter(c: Context): AgentAdapter { return crushAdapter } + if (hasLiteLLMHeaders(c)) { + return passthroughAdapter + } + return openCodeAdapter } diff --git a/src/proxy/adapters/passthrough.ts b/src/proxy/adapters/passthrough.ts new file mode 100644 index 0000000..d0028e0 --- /dev/null +++ b/src/proxy/adapters/passthrough.ts @@ -0,0 +1,160 @@ +/** + * Passthrough adapter for standard Anthropic API clients. + * + * Provides passthrough behavior for clients that manage their own tool execution loop. + * The client sends tool_use blocks to the proxy, which returns them for execution, + * rather than executing them internally via MCP. + * + * Key characteristics: + * - Manages its own tool execution loop: passthrough mode is required + * - Session: supports session headers (e.g., x-litellm-session-id) for continuity + * - CWD: extracts from blocks in prompt if available + * - Tool naming: uses "litellm" MCP server name for backward compatibility + */ + +import type { Context } from "hono" +import type { AgentAdapter } from "../adapter" +import { normalizeContent } from "../messages" + +const PASSTHROUGH_MCP_SERVER_NAME = "litellm" + +const PASSTHROUGH_ALLOWED_MCP_TOOLS: readonly string[] = [ + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__read`, + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__write`, + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__edit`, + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__bash`, + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__glob`, + `mcp__${PASSTHROUGH_MCP_SERVER_NAME}__grep`, +] + +function extractEnvFromPrompt(body: any): string | undefined { + const DEBUG = process.env.DEBUG_PROXY === "true" + + if (!body) return undefined + + let promptContent = "" + if (typeof body.prompt === "string") { + promptContent = body.prompt + } else if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + promptContent += msg.content + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "text" && block.text) { + promptContent += block.text + } + } + } + } + } + } + + const envMatch = promptContent.match(/]*>.*?cwd=["']([^"']+)["']/s) + if (envMatch) { + if (DEBUG) { + console.error(`[DEBUG passthrough adapter] Extracted CWD from : ${envMatch[1]}`) + } + return envMatch[1] + } + + const cwdMatch = promptContent.match(/cwd=["']([^"']+)["']/) + if (cwdMatch) { + if (DEBUG) { + console.error(`[DEBUG passthrough adapter] Extracted CWD from prompt: ${cwdMatch[1]}`) + } + return cwdMatch[1] + } + + return undefined +} + +export const passthroughAdapter: AgentAdapter = { + name: "passthrough", + + /** + * Clients may send session headers (e.g., x-litellm-session-id) for session continuity. + * If present, use it to maintain session across requests. + */ + getSessionId(c: Context): string | undefined { + const DEBUG = process.env.DEBUG_PROXY === "true" + const sessionId = c.req.header("x-litellm-session-id") + if (DEBUG && sessionId) { + console.error(`[DEBUG passthrough adapter] Using x-litellm-session-id: ${sessionId.substring(0, 8)}...`) + } + return sessionId + }, + + /** + * Try to extract CWD from blocks in the prompt. + * Falls back to MERIDIAN_WORKDIR env var or process.cwd(). + */ + extractWorkingDirectory(body: any): string | undefined { + return extractEnvFromPrompt(body) + }, + + normalizeContent(content: any): string { + return normalizeContent(content) + }, + + /** + * In passthrough mode, tool blocking is handled by the PreToolUse hook + * which captures all tool_use blocks for forwarding to the client. + * Empty list here allows all tools through for external execution. + */ + getBlockedBuiltinTools(): readonly string[] { + return [] + }, + + /** + * In passthrough mode, tool compatibility is managed externally. + * Empty list here since the proxy doesn't execute tools internally. + */ + getAgentIncompatibleTools(): readonly string[] { + return [] + }, + + getMcpServerName(): string { + return PASSTHROUGH_MCP_SERVER_NAME + }, + + getAllowedMcpTools(): readonly string[] { + return PASSTHROUGH_ALLOWED_MCP_TOOLS + }, + + /** + * Passthrough clients manage their own subagents internally — no SDK agent definitions needed. + */ + buildSdkAgents(_body: any, _mcpToolNames: readonly string[]): Record { + return {} + }, + + /** + * Passthrough clients don't need PreToolUse hooks for agent name correction. + * Hooks are injected automatically in passthrough mode to capture tool_use blocks. + */ + buildSdkHooks(_body: any, _sdkAgents: Record): undefined { + return undefined + }, + + /** + * No additional system context needed for passthrough clients. + */ + buildSystemContextAddendum(_body: any, _sdkAgents: Record): string { + return "" + }, + + /** + * Passthrough clients always use passthrough mode — the proxy returns tool_use blocks + * to the client for it to execute, rather than executing them internally via MCP. + * This overrides any CLAUDE_PROXY_PASSTHROUGH env var setting. + * + * Why: The client manages its own tool execution loop (the standard Anthropic + * tool_use / tool_result cycle). The proxy must forward tool_use blocks back + * so the client can execute them and send back tool_results. + */ + usesPassthrough(): boolean { + return true + }, +} diff --git a/src/proxy/session/cache.ts b/src/proxy/session/cache.ts index 8eb39b2..4cba11a 100644 --- a/src/proxy/session/cache.ts +++ b/src/proxy/session/cache.ts @@ -128,10 +128,16 @@ export function lookupSession( messages: Array<{ role: string; content: any }>, workingDirectory?: string ): LineageResult { + const DEBUG = process.env.DEBUG_PROXY === "true" + const fp = getConversationFingerprint(messages, workingDirectory) + if (sessionId) { const cached = sessionCache.get(sessionId) if (cached) { const result = verifyLineage(cached, messages, sessionId, sessionCache) + if (DEBUG) { + console.error(`[DEBUG cache] HIT → ${result.type} (claudeSessionId=${cached.claudeSessionId?.substring(0, 8)}..., cache=memory)`) + } if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } @@ -146,19 +152,27 @@ export function lookupSession( sdkMessageUuids: shared.sdkMessageUuids, } const result = verifyLineage(state, messages, sessionId, sessionCache) + if (DEBUG) { + console.error(`[DEBUG cache] HIT → SHARED FALLBACK (claudeSessionId=${shared.claudeSessionId?.substring(0, 8)}...)`) + } if (result.type === "continuation" || result.type === "compaction") { sessionCache.set(sessionId, state) } return result } + if (DEBUG) { + console.error(`[DEBUG cache] MISS → NEW (sessionId=${sessionId.substring(0, 8)}..., fp=${fp?.substring(0, 16) ?? "null"}...)`) + } return { type: "diverged" } } - const fp = getConversationFingerprint(messages, workingDirectory) if (fp) { const cached = fingerprintCache.get(fp) if (cached) { const result = verifyLineage(cached, messages, fp, fingerprintCache) + if (DEBUG) { + console.error(`[DEBUG cache] HIT → ${result.type} (claudeSessionId=${cached.claudeSessionId?.substring(0, 8)}..., cache=fingerprint)`) + } if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } @@ -173,11 +187,21 @@ export function lookupSession( sdkMessageUuids: shared.sdkMessageUuids, } const result = verifyLineage(state, messages, fp, fingerprintCache) + if (DEBUG) { + console.error(`[DEBUG cache] HIT → SHARED FALLBACK (claudeSessionId=${shared.claudeSessionId?.substring(0, 8)}..., cache=fingerprint)`) + } if (result.type === "continuation" || result.type === "compaction") { fingerprintCache.set(fp, state) } return result } + if (DEBUG) { + console.error(`[DEBUG cache] MISS → NEW (sessionId=undefined, fp=${fp.substring(0, 16)}...)`) + } + } else { + if (DEBUG) { + console.error(`[DEBUG cache] MISS → NEW (sessionId=undefined, fp=null)`) + } } return { type: "diverged" } } @@ -192,6 +216,7 @@ export function storeSession( workingDirectory?: string, sdkMessageUuids?: Array ) { + const DEBUG = process.env.DEBUG_PROXY === "true" if (!claudeSessionId) return const lineageHash = computeLineageHash(messages) const messageHashes = computeMessageHashes(messages) @@ -203,10 +228,17 @@ export function storeSession( messageHashes, sdkMessageUuids, } - // In-memory cache - if (sessionId) sessionCache.set(sessionId, state) const fp = getConversationFingerprint(messages, workingDirectory) - if (fp) fingerprintCache.set(fp, state) + // In-memory cache + if (sessionId) { + sessionCache.set(sessionId, state) + } + if (fp) { + fingerprintCache.set(fp, state) + } + if (DEBUG) { + console.error(`[DEBUG cache] STORE (sessionId=${sessionId?.substring(0, 8) ?? "null"}..., fp=${fp?.substring(0, 16) ?? "null"}..., claudeSessionId=${claudeSessionId.substring(0, 8)}...)`) + } // Shared file store (cross-proxy resume) const key = sessionId || fp if (key) storeSharedSession(key, claudeSessionId, state.messageCount, lineageHash, messageHashes, sdkMessageUuids) From 86193ad0494750a056316fb2e9b75aa4c2d1f7dd Mon Sep 17 00:00:00 2001 From: Endre Nagy Date: Tue, 31 Mar 2026 18:43:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20add=20tsx=20dev=20runtime,=20LiteLL?= =?UTF-8?q?M=20user-agent=20detection,=20and=20improved=20rate-limit=20ret?= =?UTF-8?q?ries=20-=20Add=20tsx=20as=20dev=20dependency=20and=20update=20s?= =?UTF-8?q?upervisor=20to=20prefer=20bun=20>=20tsx=20>=20npx=20-=20Detect?= =?UTF-8?q?=20LiteLLM=20by=20user-agent=20header=20(litellm/*)=20in=20addi?= =?UTF-8?q?tion=20to=20x-litellm-*=20headers=20-=20Force=20stream=3Dfalse?= =?UTF-8?q?=20for=20all=20LiteLLM=20requests=20(healthchecks=20don't=20sen?= =?UTF-8?q?d=20x-litellm-*=20headers)=20-=20Increase=20MAX=5FCONCURRENT=5F?= =?UTF-8?q?SESSIONS=20default=20from=2010=20to=2050=20-=20Increase=20rate-?= =?UTF-8?q?limit=20retry=20attempts=20(2=E2=86=923)=20and=20base=20delay?= =?UTF-8?q?=20(1s=E2=86=922s)=20with=20exponential=20backoff=20-=20Allow?= =?UTF-8?q?=20rate-limit=20retry=20even=20after=20partial=20content=20was?= =?UTF-8?q?=20yielded=20-=20Add=20DEBUG=5FPROXY=3Dtrue=20flag=20for=20deta?= =?UTF-8?q?iled=20error=20diagnosis=20-=20Add=20prefersStreaming()=20to=20?= =?UTF-8?q?AgentAdapter=20interface=20(unused=20but=20available)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++ bin/claude-proxy-supervisor.sh | 9 +- package-lock.json | 601 ++++++++++++++++++++++++++++-- package.json | 2 + src/proxy/adapter.ts | 8 + src/proxy/adapters/detect.ts | 21 +- src/proxy/adapters/passthrough.ts | 7 + src/proxy/server.ts | 85 ++++- 8 files changed, 705 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index cd2bf2b..bdaaa56 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,33 @@ ANTHROPIC_API_KEY=x ANTHROPIC_BASE_URL=http://127.0.0.1:3456 opencode The API key value doesn't matter — Meridian authenticates through your Claude Max session, not API keys. +## Install from Source + +```bash +git clone https://github.com/rynfar/meridian.git +cd meridian +npm install +``` + +Requires: Node.js >=22. Choose your preferred runtime: + +**Option 1: Build + supervisor (Node.js only)** +```bash +npm run build && npm start +``` + +**Option 2: bun (native TypeScript execution)** +```bash +bun install +bun run dev +bun run start # for auto-restart supervisor +``` + +**Option 3: tsx (no build step, Node.js only)** +```bash +npm run dev +``` + ## Why Meridian? You're paying for Claude Max. It includes programmatic access through the Claude Code SDK. But your favorite coding tools expect an Anthropic API endpoint and an API key. diff --git a/bin/claude-proxy-supervisor.sh b/bin/claude-proxy-supervisor.sh index c401ac2..0a227aa 100755 --- a/bin/claude-proxy-supervisor.sh +++ b/bin/claude-proxy-supervisor.sh @@ -15,7 +15,14 @@ cd "$SCRIPT_DIR/.." if [ -f dist/cli.js ]; then PROXY_CMD="node dist/cli.js" else - PROXY_CMD="bun run ./bin/cli.ts" + # bun preferred (native TS execution), tsx fallback (no build step) + if command -v bun >/dev/null 2>&1; then + PROXY_CMD="bun run ./bin/cli.ts" + elif command -v tsx >/dev/null 2>&1; then + PROXY_CMD="tsx ./bin/cli.ts" + else + PROXY_CMD="npx tsx ./bin/cli.ts" + fi fi SHUTTING_DOWN=0 diff --git a/package-lock.json b/package-lock.json index 21cb9bb..7099156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "opencode-claude-max-proxy", + "name": "@rynfar/meridian", "version": "1.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "opencode-claude-max-proxy", + "name": "@rynfar/meridian", "version": "1.22.1", "license": "MIT", "dependencies": { @@ -21,6 +21,7 @@ "@types/node": "^22.0.0", "glob": "^13.0.0", "hono": "^4.11.4", + "tsx": "^4.19.0", "typescript": "^5.8.2" }, "engines": { @@ -83,6 +84,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "1.19.11", "license": "MIT", @@ -393,25 +836,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", @@ -514,6 +938,16 @@ } } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -538,6 +972,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bun-types": { "version": "1.3.11", "dev": true, @@ -740,6 +1187,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -898,6 +1387,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -944,6 +1448,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "13.0.0", "dev": true, @@ -997,7 +1514,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1173,14 +1692,16 @@ } }, "node_modules/minimatch": { - "version": "10.1.1", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1364,6 +1885,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1554,6 +2085,26 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/package.json b/package.json index 7cf4e35..d59d57d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "prepublishOnly": "bun run build", "test": "bun test --path-ignore-patterns '**/*session-store*' && bun test src/__tests__/proxy-session-store.test.ts && bun test src/__tests__/session-store-pruning.test.ts && bun test src/__tests__/proxy-session-store-locking.test.ts", "typecheck": "tsc --noEmit", + "dev": "tsx ./bin/cli.ts", "proxy:direct": "bun run ./bin/cli.ts" }, "dependencies": { @@ -37,6 +38,7 @@ "@types/node": "^22.0.0", "glob": "^13.0.0", "hono": "^4.11.4", + "tsx": "^4.19.0", "typescript": "^5.8.2" }, "files": [ diff --git a/src/proxy/adapter.ts b/src/proxy/adapter.ts index 14712b1..cc09b9d 100644 --- a/src/proxy/adapter.ts +++ b/src/proxy/adapter.ts @@ -77,6 +77,14 @@ export interface AgentAdapter { */ buildSystemContextAddendum?(body: any, sdkAgents: Record): string + /** + * Whether this agent prefers non-streaming responses. + * + * When undefined, the client's body.stream setting is used, + * or defaults to true. + */ + prefersStreaming?(body: any): boolean + /** * Whether this agent uses passthrough mode for tool execution. * diff --git a/src/proxy/adapters/detect.ts b/src/proxy/adapters/detect.ts index e10e9e0..24094ad 100644 --- a/src/proxy/adapters/detect.ts +++ b/src/proxy/adapters/detect.ts @@ -22,24 +22,11 @@ import { passthroughAdapter } from "./passthrough" * Note: This detects LiteLLM-style requests, but the adapter is generically * named "passthrough" since it describes standard Anthropic API passthrough behavior. */ -const LITELLM_HEADER_PREFIX = "x-litellm-" - function hasLiteLLMHeaders(c: Context): boolean { - const headersToCheck = [ - "x-litellm-api-key", - "x-litellm-model", - "x-litellm-custom", - "x-litellm-organization", - "x-litellm-user", - "x-litellm-batch-write", - "x-litellm-success-callback", - "x-litellm-failure-callback", - "x-litellm-stream-options", - ] - for (const name of headersToCheck) { - if (c.req.header(name)) { - return true - } + if ((c.req.header("user-agent") || "").startsWith("litellm/")) return true + const headers = c.req.header() + for (const key of Object.keys(headers)) { + if (key.toLowerCase().startsWith("x-litellm-")) return true } return false } diff --git a/src/proxy/adapters/passthrough.ts b/src/proxy/adapters/passthrough.ts index d0028e0..12b074c 100644 --- a/src/proxy/adapters/passthrough.ts +++ b/src/proxy/adapters/passthrough.ts @@ -157,4 +157,11 @@ export const passthroughAdapter: AgentAdapter = { usesPassthrough(): boolean { return true }, + + /** + * LiteLLM requires non-streaming responses. + */ + prefersStreaming(_body: any): boolean { + return false + }, } diff --git a/src/proxy/server.ts b/src/proxy/server.ts index e9d4e87..73ddefc 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -29,8 +29,6 @@ import { computeMessageHashes, type LineageResult, } from "./session/lineage" -// Re-export for backwards compatibility (existing tests import from here) - import { lookupSession, storeSession, clearSessionCache, getMaxSessionsLimit, evictSession } from "./session/cache" // Re-export for backwards compatibility (existing tests import from here) export { computeLineageHash, hashMessage, computeMessageHashes } @@ -147,7 +145,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // --- Concurrency Control --- // Each request spawns an SDK subprocess (cli.js, ~11MB). Spawning multiple // simultaneously can crash the process. Serialize SDK queries with a queue. - const MAX_CONCURRENT_SESSIONS = parseInt((process.env.MERIDIAN_MAX_CONCURRENT ?? process.env.CLAUDE_PROXY_MAX_CONCURRENT) || "10", 10) + const MAX_CONCURRENT_SESSIONS = parseInt((process.env.MERIDIAN_MAX_CONCURRENT ?? process.env.CLAUDE_PROXY_MAX_CONCURRENT) || "50", 10) let activeSessions = 0 const sessionQueue: Array<{ resolve: () => void }> = [] @@ -179,9 +177,18 @@ export function createProxyServer(config: Partial = {}): ProxyServe return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => { try { const body = await c.req.json() + const DEBUG = process.env.DEBUG_PROXY === "true" + if (DEBUG) { + console.error(`[DEBUG PROXY] Incoming headers: ${JSON.stringify(c.req.header())}`) + console.error(`[DEBUG PROXY] body.stream=${body.stream}`) + } const authStatus = await getClaudeAuthStatusAsync() let model = mapModelToClaudeModel(body.model || "sonnet", authStatus?.subscriptionType) - const stream = body.stream ?? true + const hasLiteLLMHeaders = (c.req.header("user-agent") || "").startsWith("litellm/") + || Object.keys(c.req.header()).some(k => k.toLowerCase().startsWith("x-litellm-")) + const stream = hasLiteLLMHeaders + ? false + : (body.stream ?? true) const adapter = detectAdapter(c) const workingDirectory = (process.env.MERIDIAN_WORKDIR ?? process.env.CLAUDE_PROXY_WORKDIR) || adapter.extractWorkingDirectory(body) || process.cwd() @@ -467,9 +474,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // // Rate-limit retry strategy: // 1. Strip [1m] context (immediate, different model tier) - // 2. Backoff retries on base model (1s, 2s — exponential) - const MAX_RATE_LIMIT_RETRIES = 2 - const RATE_LIMIT_BASE_DELAY_MS = 1000 + // 2. Backoff retries on base model (2s, 4s, 8s — exponential) + const MAX_RATE_LIMIT_RETRIES = 3 + const RATE_LIMIT_BASE_DELAY_MS = 2000 const response = (async function* () { let rateLimitRetries = 0 @@ -494,11 +501,34 @@ export function createProxyServer(config: Partial = {}): ProxyServe } catch (error) { const errMsg = error instanceof Error ? error.message : String(error) + // Debug: log actual error for diagnosis + const DEBUG = process.env.DEBUG_PROXY === "true" + if (DEBUG) { + console.error(`[DEBUG PROXY] Non-streaming SDK error: ${errMsg}`) + if (error instanceof Error && error.stack) { + console.error(`[DEBUG PROXY] Stack: ${error.stack.split('\n').slice(0, 5).join('\n')}`) + } + } + // Never retry after response content was yielded — response is committed - if (didYieldContent) throw error + // EXCEPT for rate limit errors, which are transient and safe to retry + if (didYieldContent && !isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Content already yielded, skipping retry for non-rate-limit error`) + } + throw error + } + if (didYieldContent && isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Content yielded BUT rate limit detected - will retry anyway`) + } + } // Retry: stale undo UUID — evict session and start fresh (one-shot) if (isStaleSessionError(error)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Stale session error detected, evicting and retrying`) + } claudeLog("session.stale_uuid_retry", { mode: "non_stream", rollbackUuid: undoRollbackUuid, @@ -519,6 +549,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Rate-limit retry: first strip [1m] (free, different tier), then backoff if (isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Rate limit detected, model=${model}, hasExtendedContext=${hasExtendedContext(model)}`) + } if (hasExtendedContext(model)) { const from = model model = stripExtendedContext(model) @@ -541,6 +574,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe maxAttempts: MAX_RATE_LIMIT_RETRIES, delayMs: delay, }) + console.error(`[DEBUG PROXY] Starting backoff retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} with delay ${delay}ms`) console.error(`[PROXY] ${requestMeta.requestId} rate-limited on ${model}, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} in ${delay}ms`) await new Promise(r => setTimeout(r, delay)) continue @@ -754,9 +788,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Same transparent retry wrapper as the non-streaming path. // Rate-limit retry strategy: // 1. Strip [1m] context (immediate, different model tier) - // 2. Backoff retries on base model (1s, 2s — exponential) - const MAX_RATE_LIMIT_RETRIES = 2 - const RATE_LIMIT_BASE_DELAY_MS = 1000 + // 2. Backoff retries on base model (2s, 4s, 8s — exponential) + const MAX_RATE_LIMIT_RETRIES = 3 + const RATE_LIMIT_BASE_DELAY_MS = 2000 const response = (async function* () { let rateLimitRetries = 0 @@ -782,11 +816,34 @@ export function createProxyServer(config: Partial = {}): ProxyServe } catch (error) { const errMsg = error instanceof Error ? error.message : String(error) + // Debug: log actual error for diagnosis + const DEBUG = process.env.DEBUG_PROXY === "true" + if (DEBUG) { + console.error(`[DEBUG PROXY] Streaming SDK error: ${errMsg}`) + if (error instanceof Error && error.stack) { + console.error(`[DEBUG PROXY] Stack: ${error.stack.split('\n').slice(0, 5).join('\n')}`) + } + } + // Never retry after client-visible SSE events — response is committed - if (didYieldClientEvent) throw error + // EXCEPT for rate limit errors, which are transient and safe to retry + if (didYieldClientEvent && !isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Client-visible SSE event already yielded, skipping retry for non-rate-limit error`) + } + throw error + } + if (didYieldClientEvent && isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Client-visible SSE event yielded BUT rate limit detected - will retry anyway`) + } + } // Retry: stale undo UUID — evict and start fresh (one-shot) if (isStaleSessionError(error)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Stale session error detected, evicting and retrying`) + } claudeLog("session.stale_uuid_retry", { mode: "stream", rollbackUuid: undoRollbackUuid, @@ -807,6 +864,9 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Rate-limit retry: first strip [1m] (free, different tier), then backoff if (isRateLimitError(errMsg)) { + if (DEBUG) { + console.error(`[DEBUG PROXY] Rate limit detected, model=${model}, hasExtendedContext=${hasExtendedContext(model)}`) + } if (hasExtendedContext(model)) { const from = model model = stripExtendedContext(model) @@ -829,6 +889,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe maxAttempts: MAX_RATE_LIMIT_RETRIES, delayMs: delay, }) + console.error(`[DEBUG PROXY] Starting backoff retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} with delay ${delay}ms`) console.error(`[PROXY] ${requestMeta.requestId} rate-limited on ${model}, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} in ${delay}ms`) await new Promise(r => setTimeout(r, delay)) continue