claude-sdk-proxy 3.0.0 → 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.
@@ -2,12 +2,19 @@ export interface ProxyConfig {
2
2
  port: number
3
3
  host: string
4
4
  debug: boolean
5
- requestTimeoutMs: number
5
+ /** Abort if no SDK events received for this long (ms). Resets on every event. */
6
+ stallTimeoutMs: number
7
+ /** Hard max duration for any request (ms). Kills even actively streaming requests. Safety valve. */
8
+ maxDurationMs: number
9
+ /** Hard max output size (chars). Kills request if output exceeds this. Safety valve. */
10
+ maxOutputChars: number
6
11
  }
7
12
 
8
13
  export const DEFAULT_PROXY_CONFIG: ProxyConfig = {
9
14
  port: 3456,
10
15
  host: "127.0.0.1",
11
16
  debug: process.env.CLAUDE_PROXY_DEBUG === "1" || process.env.OPENCODE_CLAUDE_PROVIDER_DEBUG === "1",
12
- requestTimeoutMs: parseInt(process.env.CLAUDE_PROXY_TIMEOUT_MS ?? "1800000", 10),
17
+ stallTimeoutMs: parseInt(process.env.CLAUDE_PROXY_STALL_TIMEOUT_MS ?? "120000", 10),
18
+ maxDurationMs: parseInt(process.env.CLAUDE_PROXY_MAX_DURATION_MS ?? "600000", 10), // 10 minutes
19
+ maxOutputChars: parseInt(process.env.CLAUDE_PROXY_MAX_OUTPUT_CHARS ?? "500000", 10), // 500KB
13
20
  }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Session Store — Maps proxy conversation IDs to Claude SDK session IDs.
3
+ * Enables session resumption to avoid resending full conversation history.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync } from "fs"
7
+ import { join, dirname } from "path"
8
+ import { fileURLToPath } from "url"
9
+ import { logInfo, logWarn, logDebug } from "./logger"
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const STORE_PATH = join(__dirname, "..", "session-store.json")
13
+
14
+ export interface SessionEntry {
15
+ sdkSessionId: string
16
+ createdAt: number
17
+ lastUsed: number
18
+ messageCount: number
19
+ model: string
20
+ /** Number of successful resumes */
21
+ resumeCount: number
22
+ /** Number of resume failures (fell back to full context) */
23
+ failureCount: number
24
+ }
25
+
26
+ export interface SessionStoreStats {
27
+ totalSessions: number
28
+ activeSessions: number
29
+ totalResumes: number
30
+ totalFailures: number
31
+ hitRate: string
32
+ }
33
+
34
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
35
+ const MAX_SESSIONS = 500
36
+
37
+ class SessionStore {
38
+ private sessions: Map<string, SessionEntry> = new Map()
39
+ private hits = 0
40
+ private misses = 0
41
+ private ttlMs: number
42
+
43
+ constructor(ttlMs = DEFAULT_TTL_MS) {
44
+ this.ttlMs = ttlMs
45
+ this.load()
46
+ // Clear all sessions on startup — SDK sessions don't survive proxy restarts
47
+ if (this.sessions.size > 0) {
48
+ logInfo("session-store.startup_clear", { cleared: this.sessions.size })
49
+ this.sessions.clear()
50
+ this.save()
51
+ }
52
+ }
53
+
54
+ /** Get a session by conversation ID */
55
+ get(conversationId: string): SessionEntry | undefined {
56
+ const entry = this.sessions.get(conversationId)
57
+ if (!entry) {
58
+ this.misses++
59
+ return undefined
60
+ }
61
+ // Check TTL
62
+ if (Date.now() - entry.lastUsed > this.ttlMs) {
63
+ logDebug("session-store.expired", { conversationId, lastUsed: entry.lastUsed })
64
+ this.sessions.delete(conversationId)
65
+ this.misses++
66
+ this.save()
67
+ return undefined
68
+ }
69
+ this.hits++
70
+ return entry
71
+ }
72
+
73
+ /** Store or update a session mapping */
74
+ set(conversationId: string, sdkSessionId: string, model: string, messageCount: number): void {
75
+ const existing = this.sessions.get(conversationId)
76
+ this.sessions.set(conversationId, {
77
+ sdkSessionId,
78
+ createdAt: existing?.createdAt ?? Date.now(),
79
+ lastUsed: Date.now(),
80
+ messageCount,
81
+ model,
82
+ resumeCount: existing?.resumeCount ?? 0,
83
+ failureCount: existing?.failureCount ?? 0,
84
+ })
85
+ this.enforceMaxSessions()
86
+ this.save()
87
+ }
88
+
89
+ /** Record a successful resume */
90
+ recordResume(conversationId: string): void {
91
+ const entry = this.sessions.get(conversationId)
92
+ if (entry) {
93
+ entry.resumeCount++
94
+ entry.lastUsed = Date.now()
95
+ this.save()
96
+ }
97
+ }
98
+
99
+ /** Record a resume failure */
100
+ recordFailure(conversationId: string): void {
101
+ const entry = this.sessions.get(conversationId)
102
+ if (entry) {
103
+ entry.failureCount++
104
+ entry.lastUsed = Date.now()
105
+ this.save()
106
+ }
107
+ }
108
+
109
+ /** Invalidate a session (e.g., after resume failure) */
110
+ invalidate(conversationId: string): void {
111
+ this.sessions.delete(conversationId)
112
+ this.save()
113
+ }
114
+
115
+ /** Evict sessions older than TTL */
116
+ cleanup(): { evicted: number; remaining: number } {
117
+ const now = Date.now()
118
+ let evicted = 0
119
+ for (const [id, entry] of this.sessions) {
120
+ if (now - entry.lastUsed > this.ttlMs) {
121
+ this.sessions.delete(id)
122
+ evicted++
123
+ }
124
+ }
125
+ if (evicted > 0) {
126
+ logInfo("session-store.cleanup", { evicted, remaining: this.sessions.size })
127
+ this.save()
128
+ }
129
+ return { evicted, remaining: this.sessions.size }
130
+ }
131
+
132
+ /** Get stats for debug endpoint */
133
+ getStats(): SessionStoreStats & { hits: number; misses: number } {
134
+ let totalResumes = 0
135
+ let totalFailures = 0
136
+ for (const entry of this.sessions.values()) {
137
+ totalResumes += entry.resumeCount
138
+ totalFailures += entry.failureCount
139
+ }
140
+ const total = this.hits + this.misses
141
+ return {
142
+ totalSessions: this.sessions.size,
143
+ activeSessions: this.sessions.size,
144
+ totalResumes,
145
+ totalFailures,
146
+ hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : "N/A",
147
+ hits: this.hits,
148
+ misses: this.misses,
149
+ }
150
+ }
151
+
152
+ /** List all sessions (for debug) */
153
+ list(): Array<{ conversationId: string } & SessionEntry> {
154
+ return Array.from(this.sessions.entries()).map(([id, entry]) => ({
155
+ conversationId: id,
156
+ ...entry,
157
+ }))
158
+ }
159
+
160
+ private enforceMaxSessions(): void {
161
+ if (this.sessions.size <= MAX_SESSIONS) return
162
+ // Evict oldest by lastUsed
163
+ const sorted = Array.from(this.sessions.entries())
164
+ .sort((a, b) => a[1].lastUsed - b[1].lastUsed)
165
+ const toEvict = sorted.slice(0, this.sessions.size - MAX_SESSIONS)
166
+ for (const [id] of toEvict) {
167
+ this.sessions.delete(id)
168
+ }
169
+ logInfo("session-store.max_eviction", { evicted: toEvict.length, remaining: this.sessions.size })
170
+ }
171
+
172
+ private load(): void {
173
+ try {
174
+ const data = readFileSync(STORE_PATH, "utf-8")
175
+ const parsed = JSON.parse(data)
176
+ if (Array.isArray(parsed)) {
177
+ for (const [id, entry] of parsed) {
178
+ this.sessions.set(id, entry)
179
+ }
180
+ }
181
+ logDebug("session-store.loaded", { count: this.sessions.size })
182
+ } catch {
183
+ // No existing store or parse error — start fresh
184
+ logDebug("session-store.init", { path: STORE_PATH })
185
+ }
186
+ }
187
+
188
+ private save(): void {
189
+ try {
190
+ const data = JSON.stringify(Array.from(this.sessions.entries()), null, 2)
191
+ writeFileSync(STORE_PATH, data, "utf-8")
192
+ } catch (err) {
193
+ logWarn("session-store.save_failed", { error: err instanceof Error ? err.message : String(err) })
194
+ }
195
+ }
196
+ }
197
+
198
+ export const sessionStore = new SessionStore()