claude-sdk-proxy 2.3.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-sdk-proxy",
3
- "version": "2.3.2",
3
+ "version": "3.1.0",
4
4
  "description": "Anthropic Messages API proxy backed by Claude Agent SDK — use Claude Max with any API client",
5
5
  "type": "module",
6
6
  "main": "./src/proxy/server.ts",
package/src/logger.ts CHANGED
@@ -1,13 +1,132 @@
1
- const shouldLog = () =>
1
+ import { mkdirSync, appendFileSync, existsSync } from "fs"
2
+ import { join } from "path"
3
+
4
+ // ── Configuration ────────────────────────────────────────────────────────────
5
+
6
+ const LOG_DIR = process.env.CLAUDE_PROXY_LOG_DIR ?? "/tmp/claude-proxy"
7
+ const LOG_LEVEL_ENV = (process.env.CLAUDE_PROXY_LOG_LEVEL ?? "info").toLowerCase()
8
+ const IS_DEBUG =
2
9
  process.env["CLAUDE_PROXY_DEBUG"] === "1" ||
3
10
  process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"] === "1"
4
11
 
5
- export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
6
- if (!shouldLog()) return
7
- const ts = new Date().toISOString()
8
- const parts = [`[${ts}] [claude-sdk-proxy]`, message]
9
- if (extra && Object.keys(extra).length > 0) {
10
- parts.push(JSON.stringify(extra))
12
+ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const
13
+ type LogLevel = keyof typeof LEVELS
14
+
15
+ const currentLevel: number = IS_DEBUG
16
+ ? LEVELS.debug
17
+ : LEVELS[LOG_LEVEL_ENV as LogLevel] ?? LEVELS.info
18
+
19
+ // ── File output ──────────────────────────────────────────────────────────────
20
+
21
+ let currentDateStr = ""
22
+ let currentLogPath = ""
23
+
24
+ function ensureLogDir() {
25
+ try {
26
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
27
+ const errDir = join(LOG_DIR, "errors")
28
+ if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true })
29
+ } catch {}
30
+ }
31
+
32
+ ensureLogDir()
33
+
34
+ function getLogPath(): string {
35
+ const dateStr = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
36
+ if (dateStr !== currentDateStr) {
37
+ currentDateStr = dateStr
38
+ currentLogPath = join(LOG_DIR, `proxy-${dateStr}.log`)
11
39
  }
12
- console.debug(parts.join(" "))
40
+ return currentLogPath
13
41
  }
42
+
43
+ function writeToFile(line: string) {
44
+ try {
45
+ appendFileSync(getLogPath(), line + "\n")
46
+ } catch {}
47
+ }
48
+
49
+ // ── Structured JSON logging ──────────────────────────────────────────────────
50
+ // Every log line is a single JSON object — parseable by jq, greppable by reqId.
51
+ //
52
+ // Example:
53
+ // {"ts":"2026-02-23T15:30:00.000Z","level":"info","event":"proxy.request","reqId":"req_abc","model":"haiku"}
54
+ // {"ts":"2026-02-23T15:30:05.000Z","level":"error","event":"proxy.sdk.error","reqId":"req_abc","error":"timeout"}
55
+
56
+ export interface LogEntry {
57
+ ts: string
58
+ level: LogLevel
59
+ event: string
60
+ reqId?: string
61
+ [key: string]: unknown
62
+ }
63
+
64
+ function emit(level: LogLevel, event: string, data?: Record<string, unknown>) {
65
+ if (LEVELS[level] > currentLevel) return
66
+
67
+ const entry: LogEntry = {
68
+ ts: new Date().toISOString(),
69
+ level,
70
+ event,
71
+ ...data,
72
+ }
73
+
74
+ const line = JSON.stringify(entry)
75
+
76
+ // Always write to stderr (captured by journalctl)
77
+ console.error(line)
78
+
79
+ // Always write to file (persists for post-mortem)
80
+ writeToFile(line)
81
+ }
82
+
83
+ // ── Public API ───────────────────────────────────────────────────────────────
84
+
85
+ /** Log an error — always emitted. Use for failures, crashes, unexpected states. */
86
+ export function logError(event: string, data?: Record<string, unknown>) {
87
+ emit("error", event, data)
88
+ }
89
+
90
+ /** Log a warning — always emitted. Use for degraded states, retries, slow operations. */
91
+ export function logWarn(event: string, data?: Record<string, unknown>) {
92
+ emit("warn", event, data)
93
+ }
94
+
95
+ /** Log info — always emitted. Use for request lifecycle events. */
96
+ export function logInfo(event: string, data?: Record<string, unknown>) {
97
+ emit("info", event, data)
98
+ }
99
+
100
+ /** Log debug — only when CLAUDE_PROXY_DEBUG=1. Use for verbose details. */
101
+ export function logDebug(event: string, data?: Record<string, unknown>) {
102
+ emit("debug", event, data)
103
+ }
104
+
105
+ /** Write an error dump file for a specific request. Returns the file path. */
106
+ export function dumpError(reqId: string, data: Record<string, unknown>): string {
107
+ const errDir = join(LOG_DIR, "errors")
108
+ const path = join(errDir, `${reqId}.json`)
109
+ try {
110
+ if (!existsSync(errDir)) mkdirSync(errDir, { recursive: true })
111
+ const content = JSON.stringify({ ts: new Date().toISOString(), reqId, ...data }, null, 2)
112
+ appendFileSync(path, content)
113
+ } catch (e) {
114
+ logError("logger.dump_failed", { reqId, path, error: String(e) })
115
+ }
116
+ return path
117
+ }
118
+
119
+ // ── Legacy API (backward-compatible) ─────────────────────────────────────────
120
+ // These are used by existing code. They map to the new structured logging.
121
+
122
+ /** @deprecated Use logInfo instead */
123
+ export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
124
+ logInfo(message, extra)
125
+ }
126
+
127
+ /** @deprecated Use logDebug instead */
128
+ export const claudeDebug = (message: string, extra?: Record<string, unknown>) => {
129
+ logDebug(message, extra)
130
+ }
131
+
132
+ export { LOG_DIR }
package/src/mcpTools.ts CHANGED
@@ -1,207 +1,3 @@
1
- import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
2
- import { z } from "zod"
3
- import { createPrivateKey, createPublicKey, sign, randomBytes } from "node:crypto"
4
- import { readFileSync } from "node:fs"
5
- import { homedir } from "node:os"
6
-
7
- // ── Gateway helpers ──────────────────────────────────────────────────────────
8
-
9
- function b64urlEncode(buf: Buffer): string {
10
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
11
- }
12
-
13
- function signPayload(privateKeyPem: string, payload: string): string {
14
- const key = createPrivateKey(privateKeyPem)
15
- return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
16
- }
17
-
18
- function pubKeyRawB64url(publicKeyPem: string): string {
19
- const pubKey = createPublicKey(publicKeyPem)
20
- const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
21
- return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
22
- }
23
-
24
- let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
25
- let _gatewayToken: string | null = null
26
-
27
- function loadGatewayConfig(): { identity: typeof _identity; token: string } {
28
- if (!_identity || !_gatewayToken) {
29
- const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
30
- const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
31
- const token: string = cfg?.gateway?.auth?.token
32
- if (!token) throw new Error("gateway token not found in openclaw.json")
33
- _identity = identity
34
- _gatewayToken = token
35
- }
36
- return { identity: _identity!, token: _gatewayToken! }
37
- }
38
-
39
- function invalidateGatewayConfig() {
40
- _identity = null
41
- _gatewayToken = null
42
- }
43
-
44
- async function sendViaGateway(
45
- to: string,
46
- message?: string,
47
- mediaUrl?: string
48
- ): Promise<{ ok: boolean; error?: string }> {
49
- let identity: ReturnType<typeof loadGatewayConfig>["identity"]
50
- let token: string
51
- try {
52
- const cfg = loadGatewayConfig()
53
- identity = cfg.identity
54
- token = cfg.token
55
- } catch (e) {
56
- invalidateGatewayConfig()
57
- return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
58
- }
59
-
60
- return new Promise((resolve) => {
61
- const ws = new WebSocket("ws://127.0.0.1:18789")
62
- let settled = false
63
- let connected = false
64
-
65
- const finish = (result: { ok: boolean; error?: string }) => {
66
- if (settled) return
67
- settled = true
68
- clearTimeout(timer)
69
- try { ws.close() } catch {}
70
- resolve(result)
71
- }
72
-
73
- const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
74
-
75
- ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
76
-
77
- ws.onclose = (event: CloseEvent) => {
78
- if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
79
- }
80
-
81
- ws.onmessage = (event: MessageEvent) => {
82
- try {
83
- const frame = JSON.parse(event.data as string)
84
-
85
- if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
86
- const nonce: string = frame.payload.nonce
87
- const signedAtMs = Date.now()
88
- const SCOPES = ["operator.admin", "operator.write"]
89
- const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
90
- SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
91
- ws.send(JSON.stringify({
92
- type: "req", id: "conn1", method: "connect",
93
- params: {
94
- minProtocol: 3, maxProtocol: 3,
95
- client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
96
- caps: [],
97
- scopes: SCOPES,
98
- auth: { token },
99
- device: {
100
- id: identity!.deviceId,
101
- publicKey: pubKeyRawB64url(identity!.publicKeyPem),
102
- signature: signPayload(identity!.privateKeyPem, authPayload),
103
- signedAt: signedAtMs,
104
- nonce
105
- }
106
- }
107
- }))
108
-
109
- } else if (!connected && frame.type === "res" && frame.id === "conn1") {
110
- if (!frame.ok) {
111
- if (frame.error?.message?.includes("unauthorized") ||
112
- frame.error?.message?.includes("pairing")) {
113
- invalidateGatewayConfig()
114
- }
115
- finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
116
- return
117
- }
118
- connected = true
119
- const sendParams: Record<string, unknown> = {
120
- to,
121
- channel: "telegram",
122
- idempotencyKey: randomBytes(16).toString("hex")
123
- }
124
- if (message) sendParams.message = message
125
- if (mediaUrl) sendParams.mediaUrl = mediaUrl
126
- ws.send(JSON.stringify({
127
- type: "req", id: "send1", method: "send",
128
- params: sendParams
129
- }))
130
-
131
- } else if (connected && frame.type === "res" && frame.id === "send1") {
132
- if (frame.ok) {
133
- finish({ ok: true })
134
- } else {
135
- finish({ ok: false, error: frame.error?.message || "send failed" })
136
- }
137
- }
138
- } catch (e) {
139
- finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
140
- }
141
- }
142
- })
143
- }
144
-
145
- // ── MCP server factory ───────────────────────────────────────────────────────
146
- // Provides only the gateway message tool. File operations, bash, etc. use
147
- // Claude Code's built-in tools (which are more robust and don't need MCP).
148
- //
149
- // state.messageSent is set on successful delivery. The proxy uses this to
150
- // auto-suppress text responses when messages were sent via tool (prevents
151
- // double-delivery without requiring Claude to know about any sentinel value).
152
-
153
- export interface McpServerState { messageSent: boolean }
154
-
155
- export function createMcpServer(state: McpServerState = { messageSent: false }) {
156
- return createSdkMcpServer({
157
- name: "opencode",
158
- version: "1.0.0",
159
- tools: [
160
- tool(
161
- "message",
162
- "Send a message or file to a chat. Provide `to` (chat ID from conversation_label, e.g. '-1001426819337'), and either `message` (text) or `filePath`/`path`/`media` (absolute path to a file). Write files to /tmp/ before sending.",
163
- {
164
- action: z.string().optional().describe("Action to perform. Default: 'send'."),
165
- to: z.string().describe("Chat ID, extracted from conversation_label."),
166
- message: z.string().optional().describe("Text message to send."),
167
- filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
168
- path: z.string().optional().describe("Alias for filePath."),
169
- media: z.string().optional().describe("Alias for filePath."),
170
- caption: z.string().optional().describe("Caption for a media attachment."),
171
- },
172
- async (args) => {
173
- try {
174
- const rawMedia = args.media ?? args.path ?? args.filePath
175
- let mediaUrl: string | undefined
176
- if (rawMedia) {
177
- if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
178
- mediaUrl = rawMedia
179
- } else {
180
- const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
181
- mediaUrl = `file://${absPath}`
182
- }
183
- }
184
- const textMessage = args.message ?? args.caption
185
- if (!textMessage && !mediaUrl) {
186
- return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
187
- }
188
- const result = await sendViaGateway(args.to, textMessage, mediaUrl)
189
- if (result.ok) {
190
- state.messageSent = true
191
- return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
192
- }
193
- return {
194
- content: [{ type: "text", text: `Failed: ${result.error}` }],
195
- isError: true
196
- }
197
- } catch (error) {
198
- return {
199
- content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
200
- isError: true
201
- }
202
- }
203
- }
204
- )
205
- ]
206
- })
207
- }
1
+ // mcpTools.ts removed. The proxy no longer provides any MCP tools.
2
+ // Tool definitions come from the API caller (standard Anthropic tool loop).
3
+ export {}