qwenproxy-cli 1.0.0 → 1.0.1
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/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +78 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2800 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +264 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
import { getDatabase } from "../core/database.ts";
|
|
2
|
-
import { logger } from "../core/logger.ts";
|
|
3
|
-
import { isAuthMockEnabled } from "./auth-playwright.ts";
|
|
4
|
-
|
|
5
|
-
interface SessionEntry {
|
|
6
|
-
accountId: string;
|
|
7
|
-
parentId: string | null;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface LogicalThreadEntry {
|
|
12
|
-
accountId: string;
|
|
13
|
-
chatSessionId: string;
|
|
14
|
-
parentId: string | null;
|
|
15
|
-
instructionsSent: boolean;
|
|
16
|
-
timestamp: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const sessionStates: Map<string, SessionEntry> =
|
|
20
|
-
(globalThis as any)._sessionStates || new Map();
|
|
21
|
-
(globalThis as any)._sessionStates = sessionStates;
|
|
22
|
-
|
|
23
|
-
// In-memory cache for logical thread states (backed by SQLite)
|
|
24
|
-
const logicalThreadStates: Map<string, LogicalThreadEntry> =
|
|
25
|
-
(globalThis as any)._logicalThreadStates || new Map();
|
|
26
|
-
(globalThis as any)._logicalThreadStates = logicalThreadStates;
|
|
27
|
-
|
|
28
|
-
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Debounce window before dirty logical thread states are upserted to SQLite in
|
|
32
|
-
* a single transaction. The tool-call loop calls updateLogicalThreadState on
|
|
33
|
-
* every turn (~30+ synchronous writes per conversation otherwise). The
|
|
34
|
-
* in-memory cache stays the authoritative source within the process, so reads
|
|
35
|
-
* never observe the delay; flushLogicalThreadState() runs on shutdown.
|
|
36
|
-
*/
|
|
37
|
-
const LOGICAL_THREAD_FLUSH_DELAY_MS = 300;
|
|
38
|
-
|
|
39
|
-
const logicalThreadDirty: Set<string> =
|
|
40
|
-
(globalThis as any)._logicalThreadDirty || new Set();
|
|
41
|
-
(globalThis as any)._logicalThreadDirty = logicalThreadDirty;
|
|
42
|
-
|
|
43
|
-
// Pending "tool-call cap reached" notices, keyed by logical session id. Set when
|
|
44
|
-
// a turn is closed early at the per-turn tool-call cap; consumed by the NEXT
|
|
45
|
-
// turn of the same session so the model is told that calls beyond the cap were
|
|
46
|
-
// not executed (and it can re-issue them). In-memory only: a restart drops the
|
|
47
|
-
// notice, which is acceptable — the model simply continues without the hint.
|
|
48
|
-
const toolCapNotices: Map<string, number> =
|
|
49
|
-
(globalThis as any)._toolCapNotices || new Map();
|
|
50
|
-
(globalThis as any)._toolCapNotices = toolCapNotices;
|
|
51
|
-
|
|
52
|
-
/** Record that `logicalSessionId` hit the per-turn tool-call cap this turn. */
|
|
53
|
-
export function setToolCapNotice(
|
|
54
|
-
logicalSessionId: string | null | undefined,
|
|
55
|
-
): void {
|
|
56
|
-
if (!logicalSessionId) return;
|
|
57
|
-
toolCapNotices.set(logicalSessionId, Date.now());
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Consume (read + clear) the pending tool-cap notice for `logicalSessionId`.
|
|
62
|
-
* Returns true when the previous turn of this session was closed early at the
|
|
63
|
-
* cap, so the caller can inject a notice into the current turn's prompt.
|
|
64
|
-
*/
|
|
65
|
-
export function consumeToolCapNotice(
|
|
66
|
-
logicalSessionId: string | null | undefined,
|
|
67
|
-
): boolean {
|
|
68
|
-
if (!logicalSessionId) return false;
|
|
69
|
-
const ts = toolCapNotices.get(logicalSessionId);
|
|
70
|
-
if (ts === undefined) return false;
|
|
71
|
-
toolCapNotices.delete(logicalSessionId);
|
|
72
|
-
return Date.now() - ts <= SESSION_TTL_MS;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
let logicalThreadFlushTimer: NodeJS.Timeout | null = null;
|
|
76
|
-
|
|
77
|
-
function scheduleLogicalThreadFlush(): void {
|
|
78
|
-
if (logicalThreadFlushTimer) return;
|
|
79
|
-
logicalThreadFlushTimer = setTimeout(() => {
|
|
80
|
-
logicalThreadFlushTimer = null;
|
|
81
|
-
flushLogicalThreadState();
|
|
82
|
-
}, LOGICAL_THREAD_FLUSH_DELAY_MS);
|
|
83
|
-
logicalThreadFlushTimer.unref?.();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Persist every dirty logical thread state to SQLite in one transaction.
|
|
88
|
-
* Exported so the shutdown path can flush before closeDatabase().
|
|
89
|
-
*/
|
|
90
|
-
export function flushLogicalThreadState(): void {
|
|
91
|
-
if (logicalThreadFlushTimer) {
|
|
92
|
-
clearTimeout(logicalThreadFlushTimer);
|
|
93
|
-
logicalThreadFlushTimer = null;
|
|
94
|
-
}
|
|
95
|
-
if (isAuthMockEnabled()) return;
|
|
96
|
-
if (logicalThreadDirty.size === 0) return;
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const db = getDatabase();
|
|
100
|
-
const upsert = db.prepare(
|
|
101
|
-
`INSERT INTO logical_thread_states (session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at)
|
|
102
|
-
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
103
|
-
ON CONFLICT(session_id) DO UPDATE SET
|
|
104
|
-
account_id = excluded.account_id,
|
|
105
|
-
chat_session_id = excluded.chat_session_id,
|
|
106
|
-
parent_id = excluded.parent_id,
|
|
107
|
-
instructions_sent = excluded.instructions_sent,
|
|
108
|
-
updated_at = datetime('now')`,
|
|
109
|
-
);
|
|
110
|
-
const flush = db.transaction(
|
|
111
|
-
(entries: Array<{
|
|
112
|
-
sessionId: string;
|
|
113
|
-
accountId: string;
|
|
114
|
-
chatSessionId: string;
|
|
115
|
-
parentId: string | null;
|
|
116
|
-
instructionsSent: number;
|
|
117
|
-
}>) => {
|
|
118
|
-
for (const e of entries) {
|
|
119
|
-
upsert.run(
|
|
120
|
-
e.sessionId,
|
|
121
|
-
e.accountId,
|
|
122
|
-
e.chatSessionId,
|
|
123
|
-
e.parentId,
|
|
124
|
-
e.instructionsSent,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const entries: Array<{
|
|
131
|
-
sessionId: string;
|
|
132
|
-
accountId: string;
|
|
133
|
-
chatSessionId: string;
|
|
134
|
-
parentId: string | null;
|
|
135
|
-
instructionsSent: number;
|
|
136
|
-
}> = [];
|
|
137
|
-
for (const sessionId of logicalThreadDirty) {
|
|
138
|
-
const entry = logicalThreadStates.get(sessionId);
|
|
139
|
-
// Entries evicted from the cache (clearAllSessionsForAccount / TTL) must
|
|
140
|
-
// NOT be resurrected — the deletion already happened in the cache.
|
|
141
|
-
if (!entry || !entry.chatSessionId) continue;
|
|
142
|
-
entries.push({
|
|
143
|
-
sessionId,
|
|
144
|
-
accountId: entry.accountId,
|
|
145
|
-
chatSessionId: entry.chatSessionId,
|
|
146
|
-
parentId: entry.parentId ?? null,
|
|
147
|
-
instructionsSent: entry.instructionsSent ? 1 : 0,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (entries.length > 0) flush(entries);
|
|
152
|
-
logicalThreadDirty.clear();
|
|
153
|
-
} catch (err) {
|
|
154
|
-
logger.warn(
|
|
155
|
-
"[Qwen] Failed to batch-persist logical thread states to SQLite",
|
|
156
|
-
{
|
|
157
|
-
count: logicalThreadDirty.size,
|
|
158
|
-
error: err instanceof Error ? err.message : String(err),
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
logicalThreadDirty.clear();
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function cleanupStaleSessions() {
|
|
166
|
-
const now = Date.now();
|
|
167
|
-
for (const [key, entry] of sessionStates.entries()) {
|
|
168
|
-
if (now - entry.timestamp > SESSION_TTL_MS) {
|
|
169
|
-
sessionStates.delete(key);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
// Cleanup stale entries from SQLite
|
|
173
|
-
try {
|
|
174
|
-
const db = getDatabase();
|
|
175
|
-
const cutoff = new Date(now - SESSION_TTL_MS).toISOString();
|
|
176
|
-
db.prepare("DELETE FROM logical_thread_states WHERE updated_at < ?").run(
|
|
177
|
-
cutoff,
|
|
178
|
-
);
|
|
179
|
-
} catch (error) {
|
|
180
|
-
logger.warn("Failed to clean up stale logical thread states", { error });
|
|
181
|
-
}
|
|
182
|
-
for (const [key, entry] of logicalThreadStates.entries()) {
|
|
183
|
-
if (now - entry.timestamp > SESSION_TTL_MS) {
|
|
184
|
-
logicalThreadStates.delete(key);
|
|
185
|
-
logicalThreadDirty.delete(key);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function getLogicalThreadState(
|
|
191
|
-
logicalSessionId: string | null | undefined,
|
|
192
|
-
): LogicalThreadEntry | null {
|
|
193
|
-
if (!logicalSessionId) return null;
|
|
194
|
-
|
|
195
|
-
// Check in-memory cache first
|
|
196
|
-
const cached = logicalThreadStates.get(logicalSessionId);
|
|
197
|
-
if (cached && Date.now() - cached.timestamp <= SESSION_TTL_MS) {
|
|
198
|
-
return cached;
|
|
199
|
-
}
|
|
200
|
-
if (cached) {
|
|
201
|
-
logicalThreadStates.delete(logicalSessionId);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (isAuthMockEnabled()) return null;
|
|
205
|
-
|
|
206
|
-
// Fallback to SQLite
|
|
207
|
-
try {
|
|
208
|
-
const db = getDatabase();
|
|
209
|
-
const row = db
|
|
210
|
-
.prepare(
|
|
211
|
-
"SELECT session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at FROM logical_thread_states WHERE session_id = ?",
|
|
212
|
-
)
|
|
213
|
-
.get(logicalSessionId) as
|
|
214
|
-
| {
|
|
215
|
-
session_id: string;
|
|
216
|
-
account_id: string;
|
|
217
|
-
chat_session_id: string;
|
|
218
|
-
parent_id: string | null;
|
|
219
|
-
instructions_sent: number;
|
|
220
|
-
updated_at: string;
|
|
221
|
-
}
|
|
222
|
-
| undefined;
|
|
223
|
-
|
|
224
|
-
if (!row) return null;
|
|
225
|
-
|
|
226
|
-
const timestamp = new Date(row.updated_at).getTime();
|
|
227
|
-
if (Date.now() - timestamp > SESSION_TTL_MS) {
|
|
228
|
-
db.prepare("DELETE FROM logical_thread_states WHERE session_id = ?").run(
|
|
229
|
-
logicalSessionId,
|
|
230
|
-
);
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const entry: LogicalThreadEntry = {
|
|
235
|
-
accountId: row.account_id,
|
|
236
|
-
chatSessionId: row.chat_session_id,
|
|
237
|
-
parentId: row.parent_id,
|
|
238
|
-
instructionsSent: row.instructions_sent === 1,
|
|
239
|
-
timestamp,
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// Populate in-memory cache
|
|
243
|
-
logicalThreadStates.set(logicalSessionId, entry);
|
|
244
|
-
return entry;
|
|
245
|
-
} catch (err) {
|
|
246
|
-
logger.warn("[Qwen] Failed to read logical thread from SQLite", {
|
|
247
|
-
sessionId: logicalSessionId,
|
|
248
|
-
error: err instanceof Error ? err.message : String(err),
|
|
249
|
-
});
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function updateLogicalThreadState(
|
|
255
|
-
logicalSessionId: string,
|
|
256
|
-
entry: Omit<LogicalThreadEntry, "timestamp" | "instructionsSent"> & {
|
|
257
|
-
instructionsSent?: boolean;
|
|
258
|
-
},
|
|
259
|
-
): void {
|
|
260
|
-
if (
|
|
261
|
-
!logicalSessionId ||
|
|
262
|
-
entry.chatSessionId === undefined ||
|
|
263
|
-
entry.chatSessionId === null
|
|
264
|
-
)
|
|
265
|
-
return;
|
|
266
|
-
if (logicalThreadStates.size > 10000) cleanupStaleSessions();
|
|
267
|
-
const existing = logicalThreadStates.get(logicalSessionId);
|
|
268
|
-
const merged = {
|
|
269
|
-
...entry,
|
|
270
|
-
instructionsSent:
|
|
271
|
-
entry.instructionsSent ?? existing?.instructionsSent ?? false,
|
|
272
|
-
timestamp: Date.now(),
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
// Update in-memory cache
|
|
276
|
-
logicalThreadStates.set(logicalSessionId, merged);
|
|
277
|
-
|
|
278
|
-
if (isAuthMockEnabled()) return;
|
|
279
|
-
|
|
280
|
-
// Persist to SQLite with a short debounce: turns in the tool-call loop
|
|
281
|
-
// update this on every stream creation, and coalescing them into one
|
|
282
|
-
// transaction removes ~30+ synchronous writes per conversation. Reads stay
|
|
283
|
-
// cache-first, so runtime behavior is unchanged; the flush runs on shutdown.
|
|
284
|
-
logicalThreadDirty.add(logicalSessionId);
|
|
285
|
-
scheduleLogicalThreadFlush();
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function updateLogicalThreadParent(
|
|
289
|
-
logicalSessionId: string | null | undefined,
|
|
290
|
-
parentId: string | null,
|
|
291
|
-
accountId: string,
|
|
292
|
-
chatSessionId: string,
|
|
293
|
-
): void {
|
|
294
|
-
if (!logicalSessionId || !chatSessionId) return;
|
|
295
|
-
updateLogicalThreadState(logicalSessionId, {
|
|
296
|
-
accountId,
|
|
297
|
-
chatSessionId,
|
|
298
|
-
parentId,
|
|
299
|
-
instructionsSent: true,
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function updateSessionParent(
|
|
304
|
-
sessionId: string,
|
|
305
|
-
parentId: string | null,
|
|
306
|
-
accountId?: string,
|
|
307
|
-
) {
|
|
308
|
-
if (!sessionId) return;
|
|
309
|
-
|
|
310
|
-
if (sessionStates.size > 10000) {
|
|
311
|
-
cleanupStaleSessions();
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const existing = sessionStates.get(sessionId);
|
|
315
|
-
sessionStates.set(sessionId, {
|
|
316
|
-
accountId: accountId || existing?.accountId || "global",
|
|
317
|
-
parentId,
|
|
318
|
-
timestamp: Date.now(),
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Invalidate the stored parent for a logical thread without forgetting the
|
|
324
|
-
* upstream chat binding. The next request will see a missing parent and must
|
|
325
|
-
* rebuild the upstream chat with full context instead of appending to a
|
|
326
|
-
* possibly corrupted parent chain.
|
|
327
|
-
*/
|
|
328
|
-
export function invalidateLogicalThreadParent(
|
|
329
|
-
logicalSessionId: string | null | undefined,
|
|
330
|
-
): void {
|
|
331
|
-
if (!logicalSessionId) return;
|
|
332
|
-
|
|
333
|
-
const existing = getLogicalThreadState(logicalSessionId);
|
|
334
|
-
if (!existing) return;
|
|
335
|
-
|
|
336
|
-
updateSessionParent(existing.chatSessionId, null, existing.accountId);
|
|
337
|
-
updateLogicalThreadState(logicalSessionId, {
|
|
338
|
-
accountId: existing.accountId,
|
|
339
|
-
chatSessionId: existing.chatSessionId,
|
|
340
|
-
parentId: null,
|
|
341
|
-
instructionsSent: existing.instructionsSent,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function clearAllSessionsForAccount(accountId: string): void {
|
|
346
|
-
let removed = 0;
|
|
347
|
-
|
|
348
|
-
for (const [key, entry] of sessionStates.entries()) {
|
|
349
|
-
if (entry.accountId === accountId) {
|
|
350
|
-
sessionStates.delete(key);
|
|
351
|
-
removed++;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const [key, entry] of logicalThreadStates.entries()) {
|
|
356
|
-
if (entry.accountId === accountId) {
|
|
357
|
-
logicalThreadStates.delete(key);
|
|
358
|
-
// Drop pending writes too, so a later debounce flush cannot resurrect
|
|
359
|
-
// the just-deleted row.
|
|
360
|
-
logicalThreadDirty.delete(key);
|
|
361
|
-
removed++;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Also clear from SQLite
|
|
366
|
-
try {
|
|
367
|
-
const db = getDatabase();
|
|
368
|
-
const result = db
|
|
369
|
-
.prepare("DELETE FROM logical_thread_states WHERE account_id = ?")
|
|
370
|
-
.run(accountId);
|
|
371
|
-
removed += result.changes;
|
|
372
|
-
} catch {}
|
|
373
|
-
|
|
374
|
-
console.log(
|
|
375
|
-
`🧹 [Qwen] Cleared ${removed} session(s) for account ${accountId}`,
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export function getSessionParent(
|
|
380
|
-
sessionId: string,
|
|
381
|
-
accountId?: string,
|
|
382
|
-
): string | null | undefined {
|
|
383
|
-
const entry = sessionStates.get(sessionId);
|
|
384
|
-
if (!entry) return undefined;
|
|
385
|
-
if (Date.now() - entry.timestamp > SESSION_TTL_MS) {
|
|
386
|
-
sessionStates.delete(sessionId);
|
|
387
|
-
return undefined;
|
|
388
|
-
}
|
|
389
|
-
if (accountId && entry.accountId !== accountId) {
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
return entry.parentId;
|
|
393
|
-
}
|
|
1
|
+
import { getDatabase } from "../core/database.ts";
|
|
2
|
+
import { logger } from "../core/logger.ts";
|
|
3
|
+
import { isAuthMockEnabled } from "./auth-playwright.ts";
|
|
4
|
+
|
|
5
|
+
interface SessionEntry {
|
|
6
|
+
accountId: string;
|
|
7
|
+
parentId: string | null;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LogicalThreadEntry {
|
|
12
|
+
accountId: string;
|
|
13
|
+
chatSessionId: string;
|
|
14
|
+
parentId: string | null;
|
|
15
|
+
instructionsSent: boolean;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessionStates: Map<string, SessionEntry> =
|
|
20
|
+
(globalThis as any)._sessionStates || new Map();
|
|
21
|
+
(globalThis as any)._sessionStates = sessionStates;
|
|
22
|
+
|
|
23
|
+
// In-memory cache for logical thread states (backed by SQLite)
|
|
24
|
+
const logicalThreadStates: Map<string, LogicalThreadEntry> =
|
|
25
|
+
(globalThis as any)._logicalThreadStates || new Map();
|
|
26
|
+
(globalThis as any)._logicalThreadStates = logicalThreadStates;
|
|
27
|
+
|
|
28
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Debounce window before dirty logical thread states are upserted to SQLite in
|
|
32
|
+
* a single transaction. The tool-call loop calls updateLogicalThreadState on
|
|
33
|
+
* every turn (~30+ synchronous writes per conversation otherwise). The
|
|
34
|
+
* in-memory cache stays the authoritative source within the process, so reads
|
|
35
|
+
* never observe the delay; flushLogicalThreadState() runs on shutdown.
|
|
36
|
+
*/
|
|
37
|
+
const LOGICAL_THREAD_FLUSH_DELAY_MS = 300;
|
|
38
|
+
|
|
39
|
+
const logicalThreadDirty: Set<string> =
|
|
40
|
+
(globalThis as any)._logicalThreadDirty || new Set();
|
|
41
|
+
(globalThis as any)._logicalThreadDirty = logicalThreadDirty;
|
|
42
|
+
|
|
43
|
+
// Pending "tool-call cap reached" notices, keyed by logical session id. Set when
|
|
44
|
+
// a turn is closed early at the per-turn tool-call cap; consumed by the NEXT
|
|
45
|
+
// turn of the same session so the model is told that calls beyond the cap were
|
|
46
|
+
// not executed (and it can re-issue them). In-memory only: a restart drops the
|
|
47
|
+
// notice, which is acceptable — the model simply continues without the hint.
|
|
48
|
+
const toolCapNotices: Map<string, number> =
|
|
49
|
+
(globalThis as any)._toolCapNotices || new Map();
|
|
50
|
+
(globalThis as any)._toolCapNotices = toolCapNotices;
|
|
51
|
+
|
|
52
|
+
/** Record that `logicalSessionId` hit the per-turn tool-call cap this turn. */
|
|
53
|
+
export function setToolCapNotice(
|
|
54
|
+
logicalSessionId: string | null | undefined,
|
|
55
|
+
): void {
|
|
56
|
+
if (!logicalSessionId) return;
|
|
57
|
+
toolCapNotices.set(logicalSessionId, Date.now());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Consume (read + clear) the pending tool-cap notice for `logicalSessionId`.
|
|
62
|
+
* Returns true when the previous turn of this session was closed early at the
|
|
63
|
+
* cap, so the caller can inject a notice into the current turn's prompt.
|
|
64
|
+
*/
|
|
65
|
+
export function consumeToolCapNotice(
|
|
66
|
+
logicalSessionId: string | null | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
if (!logicalSessionId) return false;
|
|
69
|
+
const ts = toolCapNotices.get(logicalSessionId);
|
|
70
|
+
if (ts === undefined) return false;
|
|
71
|
+
toolCapNotices.delete(logicalSessionId);
|
|
72
|
+
return Date.now() - ts <= SESSION_TTL_MS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let logicalThreadFlushTimer: NodeJS.Timeout | null = null;
|
|
76
|
+
|
|
77
|
+
function scheduleLogicalThreadFlush(): void {
|
|
78
|
+
if (logicalThreadFlushTimer) return;
|
|
79
|
+
logicalThreadFlushTimer = setTimeout(() => {
|
|
80
|
+
logicalThreadFlushTimer = null;
|
|
81
|
+
flushLogicalThreadState();
|
|
82
|
+
}, LOGICAL_THREAD_FLUSH_DELAY_MS);
|
|
83
|
+
logicalThreadFlushTimer.unref?.();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Persist every dirty logical thread state to SQLite in one transaction.
|
|
88
|
+
* Exported so the shutdown path can flush before closeDatabase().
|
|
89
|
+
*/
|
|
90
|
+
export function flushLogicalThreadState(): void {
|
|
91
|
+
if (logicalThreadFlushTimer) {
|
|
92
|
+
clearTimeout(logicalThreadFlushTimer);
|
|
93
|
+
logicalThreadFlushTimer = null;
|
|
94
|
+
}
|
|
95
|
+
if (isAuthMockEnabled()) return;
|
|
96
|
+
if (logicalThreadDirty.size === 0) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const db = getDatabase();
|
|
100
|
+
const upsert = db.prepare(
|
|
101
|
+
`INSERT INTO logical_thread_states (session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
103
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
104
|
+
account_id = excluded.account_id,
|
|
105
|
+
chat_session_id = excluded.chat_session_id,
|
|
106
|
+
parent_id = excluded.parent_id,
|
|
107
|
+
instructions_sent = excluded.instructions_sent,
|
|
108
|
+
updated_at = datetime('now')`,
|
|
109
|
+
);
|
|
110
|
+
const flush = db.transaction(
|
|
111
|
+
(entries: Array<{
|
|
112
|
+
sessionId: string;
|
|
113
|
+
accountId: string;
|
|
114
|
+
chatSessionId: string;
|
|
115
|
+
parentId: string | null;
|
|
116
|
+
instructionsSent: number;
|
|
117
|
+
}>) => {
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
upsert.run(
|
|
120
|
+
e.sessionId,
|
|
121
|
+
e.accountId,
|
|
122
|
+
e.chatSessionId,
|
|
123
|
+
e.parentId,
|
|
124
|
+
e.instructionsSent,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const entries: Array<{
|
|
131
|
+
sessionId: string;
|
|
132
|
+
accountId: string;
|
|
133
|
+
chatSessionId: string;
|
|
134
|
+
parentId: string | null;
|
|
135
|
+
instructionsSent: number;
|
|
136
|
+
}> = [];
|
|
137
|
+
for (const sessionId of logicalThreadDirty) {
|
|
138
|
+
const entry = logicalThreadStates.get(sessionId);
|
|
139
|
+
// Entries evicted from the cache (clearAllSessionsForAccount / TTL) must
|
|
140
|
+
// NOT be resurrected — the deletion already happened in the cache.
|
|
141
|
+
if (!entry || !entry.chatSessionId) continue;
|
|
142
|
+
entries.push({
|
|
143
|
+
sessionId,
|
|
144
|
+
accountId: entry.accountId,
|
|
145
|
+
chatSessionId: entry.chatSessionId,
|
|
146
|
+
parentId: entry.parentId ?? null,
|
|
147
|
+
instructionsSent: entry.instructionsSent ? 1 : 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (entries.length > 0) flush(entries);
|
|
152
|
+
logicalThreadDirty.clear();
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.warn(
|
|
155
|
+
"[Qwen] Failed to batch-persist logical thread states to SQLite",
|
|
156
|
+
{
|
|
157
|
+
count: logicalThreadDirty.size,
|
|
158
|
+
error: err instanceof Error ? err.message : String(err),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
logicalThreadDirty.clear();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cleanupStaleSessions() {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
for (const [key, entry] of sessionStates.entries()) {
|
|
168
|
+
if (now - entry.timestamp > SESSION_TTL_MS) {
|
|
169
|
+
sessionStates.delete(key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Cleanup stale entries from SQLite
|
|
173
|
+
try {
|
|
174
|
+
const db = getDatabase();
|
|
175
|
+
const cutoff = new Date(now - SESSION_TTL_MS).toISOString();
|
|
176
|
+
db.prepare("DELETE FROM logical_thread_states WHERE updated_at < ?").run(
|
|
177
|
+
cutoff,
|
|
178
|
+
);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.warn("Failed to clean up stale logical thread states", { error });
|
|
181
|
+
}
|
|
182
|
+
for (const [key, entry] of logicalThreadStates.entries()) {
|
|
183
|
+
if (now - entry.timestamp > SESSION_TTL_MS) {
|
|
184
|
+
logicalThreadStates.delete(key);
|
|
185
|
+
logicalThreadDirty.delete(key);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getLogicalThreadState(
|
|
191
|
+
logicalSessionId: string | null | undefined,
|
|
192
|
+
): LogicalThreadEntry | null {
|
|
193
|
+
if (!logicalSessionId) return null;
|
|
194
|
+
|
|
195
|
+
// Check in-memory cache first
|
|
196
|
+
const cached = logicalThreadStates.get(logicalSessionId);
|
|
197
|
+
if (cached && Date.now() - cached.timestamp <= SESSION_TTL_MS) {
|
|
198
|
+
return cached;
|
|
199
|
+
}
|
|
200
|
+
if (cached) {
|
|
201
|
+
logicalThreadStates.delete(logicalSessionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (isAuthMockEnabled()) return null;
|
|
205
|
+
|
|
206
|
+
// Fallback to SQLite
|
|
207
|
+
try {
|
|
208
|
+
const db = getDatabase();
|
|
209
|
+
const row = db
|
|
210
|
+
.prepare(
|
|
211
|
+
"SELECT session_id, account_id, chat_session_id, parent_id, instructions_sent, updated_at FROM logical_thread_states WHERE session_id = ?",
|
|
212
|
+
)
|
|
213
|
+
.get(logicalSessionId) as
|
|
214
|
+
| {
|
|
215
|
+
session_id: string;
|
|
216
|
+
account_id: string;
|
|
217
|
+
chat_session_id: string;
|
|
218
|
+
parent_id: string | null;
|
|
219
|
+
instructions_sent: number;
|
|
220
|
+
updated_at: string;
|
|
221
|
+
}
|
|
222
|
+
| undefined;
|
|
223
|
+
|
|
224
|
+
if (!row) return null;
|
|
225
|
+
|
|
226
|
+
const timestamp = new Date(row.updated_at).getTime();
|
|
227
|
+
if (Date.now() - timestamp > SESSION_TTL_MS) {
|
|
228
|
+
db.prepare("DELETE FROM logical_thread_states WHERE session_id = ?").run(
|
|
229
|
+
logicalSessionId,
|
|
230
|
+
);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const entry: LogicalThreadEntry = {
|
|
235
|
+
accountId: row.account_id,
|
|
236
|
+
chatSessionId: row.chat_session_id,
|
|
237
|
+
parentId: row.parent_id,
|
|
238
|
+
instructionsSent: row.instructions_sent === 1,
|
|
239
|
+
timestamp,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Populate in-memory cache
|
|
243
|
+
logicalThreadStates.set(logicalSessionId, entry);
|
|
244
|
+
return entry;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
logger.warn("[Qwen] Failed to read logical thread from SQLite", {
|
|
247
|
+
sessionId: logicalSessionId,
|
|
248
|
+
error: err instanceof Error ? err.message : String(err),
|
|
249
|
+
});
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function updateLogicalThreadState(
|
|
255
|
+
logicalSessionId: string,
|
|
256
|
+
entry: Omit<LogicalThreadEntry, "timestamp" | "instructionsSent"> & {
|
|
257
|
+
instructionsSent?: boolean;
|
|
258
|
+
},
|
|
259
|
+
): void {
|
|
260
|
+
if (
|
|
261
|
+
!logicalSessionId ||
|
|
262
|
+
entry.chatSessionId === undefined ||
|
|
263
|
+
entry.chatSessionId === null
|
|
264
|
+
)
|
|
265
|
+
return;
|
|
266
|
+
if (logicalThreadStates.size > 10000) cleanupStaleSessions();
|
|
267
|
+
const existing = logicalThreadStates.get(logicalSessionId);
|
|
268
|
+
const merged = {
|
|
269
|
+
...entry,
|
|
270
|
+
instructionsSent:
|
|
271
|
+
entry.instructionsSent ?? existing?.instructionsSent ?? false,
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Update in-memory cache
|
|
276
|
+
logicalThreadStates.set(logicalSessionId, merged);
|
|
277
|
+
|
|
278
|
+
if (isAuthMockEnabled()) return;
|
|
279
|
+
|
|
280
|
+
// Persist to SQLite with a short debounce: turns in the tool-call loop
|
|
281
|
+
// update this on every stream creation, and coalescing them into one
|
|
282
|
+
// transaction removes ~30+ synchronous writes per conversation. Reads stay
|
|
283
|
+
// cache-first, so runtime behavior is unchanged; the flush runs on shutdown.
|
|
284
|
+
logicalThreadDirty.add(logicalSessionId);
|
|
285
|
+
scheduleLogicalThreadFlush();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function updateLogicalThreadParent(
|
|
289
|
+
logicalSessionId: string | null | undefined,
|
|
290
|
+
parentId: string | null,
|
|
291
|
+
accountId: string,
|
|
292
|
+
chatSessionId: string,
|
|
293
|
+
): void {
|
|
294
|
+
if (!logicalSessionId || !chatSessionId) return;
|
|
295
|
+
updateLogicalThreadState(logicalSessionId, {
|
|
296
|
+
accountId,
|
|
297
|
+
chatSessionId,
|
|
298
|
+
parentId,
|
|
299
|
+
instructionsSent: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function updateSessionParent(
|
|
304
|
+
sessionId: string,
|
|
305
|
+
parentId: string | null,
|
|
306
|
+
accountId?: string,
|
|
307
|
+
) {
|
|
308
|
+
if (!sessionId) return;
|
|
309
|
+
|
|
310
|
+
if (sessionStates.size > 10000) {
|
|
311
|
+
cleanupStaleSessions();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const existing = sessionStates.get(sessionId);
|
|
315
|
+
sessionStates.set(sessionId, {
|
|
316
|
+
accountId: accountId || existing?.accountId || "global",
|
|
317
|
+
parentId,
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Invalidate the stored parent for a logical thread without forgetting the
|
|
324
|
+
* upstream chat binding. The next request will see a missing parent and must
|
|
325
|
+
* rebuild the upstream chat with full context instead of appending to a
|
|
326
|
+
* possibly corrupted parent chain.
|
|
327
|
+
*/
|
|
328
|
+
export function invalidateLogicalThreadParent(
|
|
329
|
+
logicalSessionId: string | null | undefined,
|
|
330
|
+
): void {
|
|
331
|
+
if (!logicalSessionId) return;
|
|
332
|
+
|
|
333
|
+
const existing = getLogicalThreadState(logicalSessionId);
|
|
334
|
+
if (!existing) return;
|
|
335
|
+
|
|
336
|
+
updateSessionParent(existing.chatSessionId, null, existing.accountId);
|
|
337
|
+
updateLogicalThreadState(logicalSessionId, {
|
|
338
|
+
accountId: existing.accountId,
|
|
339
|
+
chatSessionId: existing.chatSessionId,
|
|
340
|
+
parentId: null,
|
|
341
|
+
instructionsSent: existing.instructionsSent,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function clearAllSessionsForAccount(accountId: string): void {
|
|
346
|
+
let removed = 0;
|
|
347
|
+
|
|
348
|
+
for (const [key, entry] of sessionStates.entries()) {
|
|
349
|
+
if (entry.accountId === accountId) {
|
|
350
|
+
sessionStates.delete(key);
|
|
351
|
+
removed++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [key, entry] of logicalThreadStates.entries()) {
|
|
356
|
+
if (entry.accountId === accountId) {
|
|
357
|
+
logicalThreadStates.delete(key);
|
|
358
|
+
// Drop pending writes too, so a later debounce flush cannot resurrect
|
|
359
|
+
// the just-deleted row.
|
|
360
|
+
logicalThreadDirty.delete(key);
|
|
361
|
+
removed++;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Also clear from SQLite
|
|
366
|
+
try {
|
|
367
|
+
const db = getDatabase();
|
|
368
|
+
const result = db
|
|
369
|
+
.prepare("DELETE FROM logical_thread_states WHERE account_id = ?")
|
|
370
|
+
.run(accountId);
|
|
371
|
+
removed += result.changes;
|
|
372
|
+
} catch {}
|
|
373
|
+
|
|
374
|
+
console.log(
|
|
375
|
+
`🧹 [Qwen] Cleared ${removed} session(s) for account ${accountId}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function getSessionParent(
|
|
380
|
+
sessionId: string,
|
|
381
|
+
accountId?: string,
|
|
382
|
+
): string | null | undefined {
|
|
383
|
+
const entry = sessionStates.get(sessionId);
|
|
384
|
+
if (!entry) return undefined;
|
|
385
|
+
if (Date.now() - entry.timestamp > SESSION_TTL_MS) {
|
|
386
|
+
sessionStates.delete(sessionId);
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
if (accountId && entry.accountId !== accountId) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return entry.parentId;
|
|
393
|
+
}
|