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.
- package/package.json +1 -1
- package/src/logger.ts +127 -8
- package/src/proxy/server.ts +992 -144
- package/src/proxy/types.ts +9 -2
- package/src/session-store.ts +198 -0
- package/src/trace.ts +633 -0
package/src/proxy/types.ts
CHANGED
|
@@ -2,12 +2,19 @@ export interface ProxyConfig {
|
|
|
2
2
|
port: number
|
|
3
3
|
host: string
|
|
4
4
|
debug: boolean
|
|
5
|
-
|
|
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
|
-
|
|
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()
|