@pedrofariasx/qwenproxy 1.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/LICENSE +13 -0
- package/README.md +292 -0
- package/bin/qwenproxy.mjs +11 -0
- package/package.json +56 -0
- package/src/api/models.ts +183 -0
- package/src/api/server.ts +126 -0
- package/src/cache/memory-cache.ts +186 -0
- package/src/core/account-manager.ts +132 -0
- package/src/core/accounts.ts +78 -0
- package/src/core/config.ts +91 -0
- package/src/core/database.ts +92 -0
- package/src/core/logger.ts +96 -0
- package/src/core/metrics.ts +169 -0
- package/src/core/model-registry.ts +30 -0
- package/src/core/stream-registry.ts +40 -0
- package/src/core/watchdog.ts +130 -0
- package/src/index.ts +7 -0
- package/src/linter/extraction-engine.ts +165 -0
- package/src/linter/index.ts +258 -0
- package/src/linter/repair-normalize.ts +245 -0
- package/src/linter/safety-gate.ts +219 -0
- package/src/linter/streaming-state-machine.ts +252 -0
- package/src/linter/structural-parser.ts +352 -0
- package/src/linter/types.ts +74 -0
- package/src/login.ts +228 -0
- package/src/routes/chat.ts +801 -0
- package/src/routes/upload.ts +700 -0
- package/src/services/playwright.ts +778 -0
- package/src/services/qwen.ts +500 -0
- package/src/tests/advanced.test.ts +227 -0
- package/src/tests/agenticStress.test.ts +360 -0
- package/src/tests/concurrency.test.ts +103 -0
- package/src/tests/concurrentChat.test.ts +71 -0
- package/src/tests/delta.test.ts +63 -0
- package/src/tests/index.test.ts +356 -0
- package/src/tests/jsonFix.test.ts +98 -0
- package/src/tests/linter.test.ts +151 -0
- package/src/tests/parallel.test.ts +42 -0
- package/src/tests/parser.test.ts +89 -0
- package/src/tests/rotation.test.ts +45 -0
- package/src/tests/streamingOptimizations.test.ts +328 -0
- package/src/tests/structureVerification.test.ts +176 -0
- package/src/tools/ast.ts +15 -0
- package/src/tools/coercion.ts +67 -0
- package/src/tools/confidence.ts +48 -0
- package/src/tools/detector.ts +40 -0
- package/src/tools/executor.ts +236 -0
- package/src/tools/parser.ts +446 -0
- package/src/tools/pipeline.ts +122 -0
- package/src/tools/registry-runtime.ts +34 -0
- package/src/tools/registry.ts +142 -0
- package/src/tools/repair.ts +42 -0
- package/src/tools/schema.ts +285 -0
- package/src/tools/types.ts +104 -0
- package/src/tools/validator.ts +33 -0
- package/src/utils/context-truncation.ts +61 -0
- package/src/utils/json.ts +114 -0
- package/src/utils/qwen-stream-parser.ts +286 -0
- package/src/utils/types.ts +101 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { config } from '../core/config.js'
|
|
2
|
+
import { metrics } from '../core/metrics.js'
|
|
3
|
+
|
|
4
|
+
export type CacheKey =
|
|
5
|
+
| `auth:${string}`
|
|
6
|
+
| `session:${string}`
|
|
7
|
+
| `prompt:${string}`
|
|
8
|
+
| `response:${string}`
|
|
9
|
+
| `rate:${string}`
|
|
10
|
+
| `models:${string}`
|
|
11
|
+
|
|
12
|
+
interface CacheEntry<T> {
|
|
13
|
+
value: T
|
|
14
|
+
expiresAt: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryCache {
|
|
18
|
+
private store: Map<string, CacheEntry<any>>
|
|
19
|
+
private defaultTTL: number
|
|
20
|
+
private prefix: string
|
|
21
|
+
private cleanupInterval: NodeJS.Timeout | null
|
|
22
|
+
|
|
23
|
+
constructor(options?: { prefix?: string; defaultTTL?: number }) {
|
|
24
|
+
this.prefix = options?.prefix || 'qwenproxy:'
|
|
25
|
+
this.defaultTTL = options?.defaultTTL || config.cache.defaultTTL
|
|
26
|
+
this.store = new Map()
|
|
27
|
+
this.cleanupInterval = null
|
|
28
|
+
|
|
29
|
+
this.startCleanup()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private startCleanup(): void {
|
|
33
|
+
this.cleanupInterval = setInterval(() => {
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
for (const [key, entry] of this.store.entries()) {
|
|
36
|
+
if (entry.expiresAt <= now) {
|
|
37
|
+
this.store.delete(key)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, 60000)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async connect(): Promise<void> {
|
|
44
|
+
// No-op for in-memory cache
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async set<T>(key: CacheKey, value: T, ttl?: number): Promise<void> {
|
|
48
|
+
const serialized = JSON.stringify(value)
|
|
49
|
+
const effectiveTTL = ttl || this.defaultTTL
|
|
50
|
+
const fullKey = this.prefix + key
|
|
51
|
+
|
|
52
|
+
this.store.set(fullKey, {
|
|
53
|
+
value,
|
|
54
|
+
expiresAt: Date.now() + (effectiveTTL * 1000)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
metrics.increment('cache.set')
|
|
58
|
+
metrics.histogram('cache.value.size', Buffer.byteLength(serialized))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async get<T>(key: CacheKey): Promise<T | null> {
|
|
62
|
+
const start = Date.now()
|
|
63
|
+
const fullKey = this.prefix + key
|
|
64
|
+
const entry = this.store.get(fullKey)
|
|
65
|
+
|
|
66
|
+
metrics.histogram('cache.get.latency', Date.now() - start)
|
|
67
|
+
|
|
68
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
69
|
+
if (entry) this.store.delete(fullKey)
|
|
70
|
+
metrics.increment('cache.miss')
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
metrics.increment('cache.hit')
|
|
75
|
+
return entry.value as T
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delete(key: CacheKey): Promise<void> {
|
|
79
|
+
const fullKey = this.prefix + key
|
|
80
|
+
this.store.delete(fullKey)
|
|
81
|
+
metrics.increment('cache.deleted')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async exists(key: CacheKey): Promise<boolean> {
|
|
85
|
+
const fullKey = this.prefix + key
|
|
86
|
+
const entry = this.store.get(fullKey)
|
|
87
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
88
|
+
if (entry) this.store.delete(fullKey)
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async setWithNX<T>(key: CacheKey, value: T, ttl?: number): Promise<boolean> {
|
|
95
|
+
const fullKey = this.prefix + key
|
|
96
|
+
if (this.store.has(fullKey)) {
|
|
97
|
+
const entry = this.store.get(fullKey)
|
|
98
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await this.set(key, value, ttl)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async increment(key: CacheKey, by: number = 1, ttl?: number): Promise<number> {
|
|
107
|
+
const fullKey = this.prefix + key
|
|
108
|
+
const entry = this.store.get(fullKey)
|
|
109
|
+
let current = 0
|
|
110
|
+
|
|
111
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
112
|
+
current = typeof entry.value === 'number' ? entry.value : 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const newValue = current + by
|
|
116
|
+
const effectiveTTL = ttl || this.defaultTTL
|
|
117
|
+
|
|
118
|
+
this.store.set(fullKey, {
|
|
119
|
+
value: newValue,
|
|
120
|
+
expiresAt: Date.now() + (effectiveTTL * 1000)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return newValue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getMulti<T>(keys: CacheKey[]): Promise<(T | null)[]> {
|
|
127
|
+
return Promise.all(keys.map(key => this.get<T>(key)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async scan(pattern: string, _count: number = 100): Promise<string[]> {
|
|
131
|
+
const regex = new RegExp(this.prefix + pattern.replace(/\*/g, '.*'))
|
|
132
|
+
const now = Date.now()
|
|
133
|
+
const keys: string[] = []
|
|
134
|
+
|
|
135
|
+
for (const [key, entry] of this.store.entries()) {
|
|
136
|
+
if (regex.test(key) && entry.expiresAt > now) {
|
|
137
|
+
keys.push(key)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return keys
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async flush(pattern?: string): Promise<void> {
|
|
144
|
+
if (pattern) {
|
|
145
|
+
const keys = await this.scan(pattern)
|
|
146
|
+
for (const key of keys) {
|
|
147
|
+
this.store.delete(key)
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
this.store.clear()
|
|
151
|
+
}
|
|
152
|
+
metrics.increment('cache.flushed')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getStats(): Promise<{
|
|
156
|
+
connected: boolean
|
|
157
|
+
keysCount?: number
|
|
158
|
+
memoryUsage?: string
|
|
159
|
+
}> {
|
|
160
|
+
const now = Date.now()
|
|
161
|
+
let validKeys = 0
|
|
162
|
+
let totalBytes = 0
|
|
163
|
+
for (const [key, entry] of this.store.entries()) {
|
|
164
|
+
if (entry.expiresAt > now) {
|
|
165
|
+
validKeys++
|
|
166
|
+
totalBytes += Buffer.byteLength(JSON.stringify(entry.value)) + Buffer.byteLength(key)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
connected: true,
|
|
172
|
+
keysCount: validKeys,
|
|
173
|
+
memoryUsage: `${(totalBytes / 1024).toFixed(2)}KB`
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async close(): Promise<void> {
|
|
178
|
+
if (this.cleanupInterval) {
|
|
179
|
+
clearInterval(this.cleanupInterval)
|
|
180
|
+
this.cleanupInterval = null
|
|
181
|
+
}
|
|
182
|
+
this.store.clear()
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const cache = new MemoryCache()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { QwenAccount, loadAccounts } from './accounts.ts'
|
|
2
|
+
import { config } from './config.js'
|
|
3
|
+
|
|
4
|
+
let currentIndex = 0
|
|
5
|
+
|
|
6
|
+
interface CooldownEntry {
|
|
7
|
+
until: number
|
|
8
|
+
reason: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const cooldowns = new Map<string, CooldownEntry>()
|
|
12
|
+
|
|
13
|
+
const DEFAULT_COOLDOWN_MS = 3 * 60 * 1000 // 3 minutes
|
|
14
|
+
|
|
15
|
+
let accountsCache: QwenAccount[] | null = null
|
|
16
|
+
let accountsCacheTimestamp = 0
|
|
17
|
+
const ACCOUNTS_CACHE_TTL = config.cache.defaultTTL * 1000
|
|
18
|
+
|
|
19
|
+
function getCachedAccounts(): QwenAccount[] {
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
|
|
22
|
+
accountsCache = loadAccounts()
|
|
23
|
+
accountsCacheTimestamp = now
|
|
24
|
+
}
|
|
25
|
+
return accountsCache
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function invalidateAccountsCache(): void {
|
|
29
|
+
accountsCache = null
|
|
30
|
+
accountsCacheTimestamp = 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function markAccountRateLimited(accountId: string, cooldownMs?: number, reason?: string): void {
|
|
34
|
+
cooldowns.set(accountId, {
|
|
35
|
+
until: Date.now() + (cooldownMs ?? DEFAULT_COOLDOWN_MS),
|
|
36
|
+
reason: reason ?? 'RateLimited',
|
|
37
|
+
})
|
|
38
|
+
console.log(`[AccountManager] Account ${accountId} marked as rate-limited. Cooldown until ${new Date(Date.now() + (cooldownMs ?? DEFAULT_COOLDOWN_MS)).toISOString()}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearAccountCooldown(accountId: string): void {
|
|
42
|
+
cooldowns.delete(accountId)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getAccountCooldownInfo(accountId: string): { onCooldown: boolean; remainingMs: number; reason: string } | null {
|
|
46
|
+
const entry = cooldowns.get(accountId)
|
|
47
|
+
if (!entry) return null
|
|
48
|
+
const remaining = entry.until - Date.now()
|
|
49
|
+
if (remaining <= 0) {
|
|
50
|
+
cooldowns.delete(accountId)
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
return { onCooldown: true, remainingMs: remaining, reason: entry.reason }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAccountOnCooldown(accountId: string): boolean {
|
|
57
|
+
return getAccountCooldownInfo(accountId) !== null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getNextAccount(forceReset?: boolean): QwenAccount | null {
|
|
61
|
+
const accounts = getCachedAccounts()
|
|
62
|
+
if (accounts.length === 0) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (forceReset) {
|
|
67
|
+
currentIndex = 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
71
|
+
const account = accounts[currentIndex % accounts.length]
|
|
72
|
+
currentIndex = (currentIndex + 1) % accounts.length
|
|
73
|
+
if (!isAccountOnCooldown(account.id)) {
|
|
74
|
+
return account
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// All accounts on cooldown — return the one with the shortest remaining cooldown
|
|
79
|
+
let best: QwenAccount | null = null
|
|
80
|
+
let bestRemaining = Infinity
|
|
81
|
+
for (const account of accounts) {
|
|
82
|
+
const info = getAccountCooldownInfo(account.id)
|
|
83
|
+
if (info && info.remainingMs < bestRemaining) {
|
|
84
|
+
bestRemaining = info.remainingMs
|
|
85
|
+
best = account
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return best
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getNextAvailableAccount(skipAccountId?: string): QwenAccount | null {
|
|
92
|
+
const accounts = getCachedAccounts()
|
|
93
|
+
if (accounts.length === 0) return null
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
96
|
+
const idx = (currentIndex + i) % accounts.length
|
|
97
|
+
const account = accounts[idx]
|
|
98
|
+
if (skipAccountId && account.id === skipAccountId) continue
|
|
99
|
+
if (!isAccountOnCooldown(account.id)) {
|
|
100
|
+
currentIndex = (idx + 1) % accounts.length
|
|
101
|
+
return account
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// All remaining accounts on cooldown — return the one with shortest cooldown
|
|
106
|
+
let best: QwenAccount | null = null
|
|
107
|
+
let bestRemaining = Infinity
|
|
108
|
+
for (const account of accounts) {
|
|
109
|
+
if (skipAccountId && account.id === skipAccountId) continue
|
|
110
|
+
const info = getAccountCooldownInfo(account.id)
|
|
111
|
+
if (info && info.remainingMs < bestRemaining) {
|
|
112
|
+
bestRemaining = info.remainingMs
|
|
113
|
+
best = account
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return best
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getAccountCount(): number {
|
|
120
|
+
return getCachedAccounts().length
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getCooldownStatus(): Record<string, { remainingMs: number; reason: string }> {
|
|
124
|
+
const result: Record<string, { remainingMs: number; reason: string }> = {}
|
|
125
|
+
for (const [id, info] of cooldowns.entries()) {
|
|
126
|
+
const remaining = info.until - Date.now()
|
|
127
|
+
if (remaining > 0) {
|
|
128
|
+
result[id] = { remainingMs: remaining, reason: info.reason }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import { getDatabase } from './database.ts'
|
|
3
|
+
import { config } from './config.js'
|
|
4
|
+
|
|
5
|
+
export interface QwenAccount {
|
|
6
|
+
id: string
|
|
7
|
+
email: string
|
|
8
|
+
password: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let accountsCache: QwenAccount[] | null = null
|
|
12
|
+
let accountsCacheTimestamp = 0
|
|
13
|
+
const ACCOUNTS_CACHE_TTL = config.cache.defaultTTL * 1000
|
|
14
|
+
|
|
15
|
+
function getCachedAccounts(): QwenAccount[] {
|
|
16
|
+
const now = Date.now()
|
|
17
|
+
if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
|
|
18
|
+
const db = getDatabase()
|
|
19
|
+
accountsCache = db.prepare('SELECT id, email, password FROM accounts ORDER BY created_at ASC').all() as QwenAccount[]
|
|
20
|
+
accountsCacheTimestamp = now
|
|
21
|
+
}
|
|
22
|
+
return accountsCache
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function invalidateAccountsCache(): void {
|
|
26
|
+
accountsCache = null
|
|
27
|
+
accountsCacheTimestamp = 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadAccounts(): QwenAccount[] {
|
|
31
|
+
return getCachedAccounts()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function addAccount(email: string, password: string, id?: string): QwenAccount {
|
|
35
|
+
if (!email || typeof email !== 'string' || email.trim().length === 0) {
|
|
36
|
+
throw new Error('Email is required')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const db = getDatabase()
|
|
40
|
+
const existing = db.prepare('SELECT id FROM accounts WHERE email = ?').get(email.trim())
|
|
41
|
+
if (existing) {
|
|
42
|
+
throw new Error(`Account with email ${email} already exists`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const newAccount: QwenAccount = {
|
|
46
|
+
id: id || crypto.randomUUID(),
|
|
47
|
+
email: email.trim(),
|
|
48
|
+
password,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
db.prepare('INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)').run(
|
|
52
|
+
newAccount.id,
|
|
53
|
+
newAccount.email,
|
|
54
|
+
newAccount.password,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
invalidateAccountsCache()
|
|
58
|
+
return newAccount
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function removeAccount(id: string): boolean {
|
|
62
|
+
const db = getDatabase()
|
|
63
|
+
const result = db.prepare('DELETE FROM accounts WHERE id = ?').run(id)
|
|
64
|
+
if (result.changes > 0) {
|
|
65
|
+
invalidateAccountsCache()
|
|
66
|
+
}
|
|
67
|
+
return result.changes > 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listAccounts(): QwenAccount[] {
|
|
71
|
+
return getCachedAccounts().map(a => ({ id: a.id, email: a.email, password: '***' }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getAccountCredentials(id: string): QwenAccount | undefined {
|
|
75
|
+
const db = getDatabase()
|
|
76
|
+
const row = db.prepare('SELECT id, email, password FROM accounts WHERE id = ?').get(id)
|
|
77
|
+
return row as QwenAccount | undefined
|
|
78
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const envSchema = z.object({
|
|
4
|
+
PORT: z.string().default('3000'),
|
|
5
|
+
HOST: z.string().default('0.0.0.0'),
|
|
6
|
+
HEADLESS: z.string().default('true'),
|
|
7
|
+
BROWSER: z.enum(['chromium', 'firefox', 'webkit', 'chrome', 'edge']).default('chromium'),
|
|
8
|
+
USER_DATA_DIR: z.string().default('./qwen_profiles'),
|
|
9
|
+
USER_AGENT: z.string().default('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'),
|
|
10
|
+
LOG_CONSOLE: z.string().default('false'),
|
|
11
|
+
NAVIGATION_TIMEOUT: z.string().default('30000'),
|
|
12
|
+
PAGE_TIMEOUT: z.string().default('15000'),
|
|
13
|
+
HTTP_TIMEOUT: z.string().default('10000'),
|
|
14
|
+
CHAT_TIMEOUT: z.string().default('120000'),
|
|
15
|
+
CACHE_TTL: z.string().default('3600'),
|
|
16
|
+
RESPONSE_TTL: z.string().default('1800'),
|
|
17
|
+
METRICS_INTERVAL: z.string().default('10000'),
|
|
18
|
+
WATCHDOG_INTERVAL: z.string().default('5000'),
|
|
19
|
+
WATCHDOG_FAILURES: z.string().default('3'),
|
|
20
|
+
RAM_WARNING: z.string().default('80'),
|
|
21
|
+
RAM_CRITICAL: z.string().default('95'),
|
|
22
|
+
WS_WARNING: z.string().default('50'),
|
|
23
|
+
WS_CRITICAL: z.string().default('100'),
|
|
24
|
+
QWEN_BASE_URL: z.string().default('https://chat.qwen.ai'),
|
|
25
|
+
QWEN_HTTP_ENDPOINT: z.string().default('https://api.qwen.ai/v1/chat'),
|
|
26
|
+
QWEN_API_KEY: z.string().default(''),
|
|
27
|
+
API_KEY: z.string().default(''),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const env = envSchema.parse(process.env)
|
|
31
|
+
|
|
32
|
+
export const config = {
|
|
33
|
+
server: {
|
|
34
|
+
port: parseInt(env.PORT),
|
|
35
|
+
host: env.HOST,
|
|
36
|
+
},
|
|
37
|
+
browser: {
|
|
38
|
+
headless: env.HEADLESS !== 'false',
|
|
39
|
+
type: env.BROWSER,
|
|
40
|
+
userDataDir: env.USER_DATA_DIR,
|
|
41
|
+
userAgent: env.USER_AGENT,
|
|
42
|
+
args: [
|
|
43
|
+
'--disable-gpu',
|
|
44
|
+
'--disable-dev-shm-usage',
|
|
45
|
+
'--disable-accelerated-2d-canvas',
|
|
46
|
+
'--no-first-run',
|
|
47
|
+
'--no-zygote',
|
|
48
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
49
|
+
],
|
|
50
|
+
launchTimeout: 30000,
|
|
51
|
+
healthCheckInterval: 30000,
|
|
52
|
+
headers: {
|
|
53
|
+
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
54
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
55
|
+
},
|
|
56
|
+
logConsole: env.LOG_CONSOLE === 'true',
|
|
57
|
+
},
|
|
58
|
+
timeouts: {
|
|
59
|
+
navigation: parseInt(env.NAVIGATION_TIMEOUT),
|
|
60
|
+
page: parseInt(env.PAGE_TIMEOUT),
|
|
61
|
+
http: parseInt(env.HTTP_TIMEOUT),
|
|
62
|
+
chat: parseInt(env.CHAT_TIMEOUT),
|
|
63
|
+
},
|
|
64
|
+
cache: {
|
|
65
|
+
defaultTTL: parseInt(env.CACHE_TTL),
|
|
66
|
+
responseTTL: parseInt(env.RESPONSE_TTL),
|
|
67
|
+
},
|
|
68
|
+
metrics: {
|
|
69
|
+
interval: parseInt(env.METRICS_INTERVAL),
|
|
70
|
+
},
|
|
71
|
+
watchdog: {
|
|
72
|
+
checkInterval: parseInt(env.WATCHDOG_INTERVAL),
|
|
73
|
+
consecutiveFailuresThreshold: parseInt(env.WATCHDOG_FAILURES),
|
|
74
|
+
ram: {
|
|
75
|
+
warningThreshold: parseInt(env.RAM_WARNING),
|
|
76
|
+
criticalThreshold: parseInt(env.RAM_CRITICAL),
|
|
77
|
+
},
|
|
78
|
+
streams: {
|
|
79
|
+
warningThreshold: parseInt(env.WS_WARNING),
|
|
80
|
+
criticalThreshold: parseInt(env.WS_CRITICAL),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
apiKey: env.API_KEY,
|
|
84
|
+
qwen: {
|
|
85
|
+
baseUrl: env.QWEN_BASE_URL,
|
|
86
|
+
httpEndpoint: env.QWEN_HTTP_ENDPOINT,
|
|
87
|
+
apiKey: env.QWEN_API_KEY,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Config = typeof config
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import fs from 'fs'
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = path.resolve('data')
|
|
6
|
+
const DB_PATH = path.join(DATA_DIR, 'qwenproxy.db')
|
|
7
|
+
|
|
8
|
+
let db: Database.Database | null = null
|
|
9
|
+
|
|
10
|
+
export function getDatabase(): Database.Database {
|
|
11
|
+
if (db) return db
|
|
12
|
+
|
|
13
|
+
// Ensure data directory exists
|
|
14
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
15
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
db = new Database(DB_PATH)
|
|
19
|
+
|
|
20
|
+
// Enable WAL mode for better concurrent read performance (ideal for VPS)
|
|
21
|
+
db.pragma('journal_mode = WAL')
|
|
22
|
+
db.pragma('busy_timeout = 5000')
|
|
23
|
+
db.pragma('synchronous = NORMAL')
|
|
24
|
+
db.pragma('cache_size = -64000') // 64MB cache
|
|
25
|
+
db.pragma('foreign_keys = ON')
|
|
26
|
+
|
|
27
|
+
runMigrations(db)
|
|
28
|
+
migrateFromJson(db)
|
|
29
|
+
|
|
30
|
+
return db
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function runMigrations(db: Database.Database): void {
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
email TEXT UNIQUE NOT NULL,
|
|
38
|
+
password TEXT NOT NULL DEFAULT '',
|
|
39
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
40
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
|
|
44
|
+
`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Auto-migrate existing accounts.json into SQLite on first run.
|
|
49
|
+
* The JSON file is renamed to accounts.json.bak after successful migration.
|
|
50
|
+
*/
|
|
51
|
+
function migrateFromJson(db: Database.Database): void {
|
|
52
|
+
const jsonPath = path.resolve('accounts.json')
|
|
53
|
+
if (!fs.existsSync(jsonPath)) return
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const raw = fs.readFileSync(jsonPath, 'utf-8')
|
|
57
|
+
const accounts = JSON.parse(raw) as Array<{ id: string; email: string; password: string }>
|
|
58
|
+
|
|
59
|
+
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
60
|
+
// Empty or invalid file — just rename it
|
|
61
|
+
fs.renameSync(jsonPath, jsonPath + '.bak')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const insert = db.prepare(`
|
|
66
|
+
INSERT OR IGNORE INTO accounts (id, email, password) VALUES (?, ?, ?)
|
|
67
|
+
`)
|
|
68
|
+
|
|
69
|
+
const migrate = db.transaction(() => {
|
|
70
|
+
for (const account of accounts) {
|
|
71
|
+
if (account.id && typeof account.email === 'string' && account.email.trim().length > 0) {
|
|
72
|
+
insert.run(account.id, account.email.trim(), account.password || '')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
migrate()
|
|
78
|
+
|
|
79
|
+
// Rename old file to .bak to avoid re-migration
|
|
80
|
+
fs.renameSync(jsonPath, jsonPath + '.bak')
|
|
81
|
+
console.log(`[Database] Migrated ${accounts.length} account(s) from accounts.json to SQLite`)
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
console.error('[Database] Failed to migrate accounts.json:', err.message)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function closeDatabase(): void {
|
|
88
|
+
if (db) {
|
|
89
|
+
db.close()
|
|
90
|
+
db = null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
export interface LogEntry {
|
|
4
|
+
timestamp: Date;
|
|
5
|
+
level: LogLevel;
|
|
6
|
+
message: string;
|
|
7
|
+
context?: string;
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Logger {
|
|
12
|
+
private minLevel: LogLevel;
|
|
13
|
+
private context?: string;
|
|
14
|
+
|
|
15
|
+
constructor(level: LogLevel = 'info', context?: string) {
|
|
16
|
+
this.minLevel = level;
|
|
17
|
+
this.context = context;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private shouldLog(level: LogLevel): boolean {
|
|
21
|
+
const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
|
|
22
|
+
return levels.indexOf(level) >= levels.indexOf(this.minLevel);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private formatEntry(entry: LogEntry): string {
|
|
26
|
+
const timestamp = entry.timestamp.toISOString();
|
|
27
|
+
const pad = (str: string): string => str.padStart(5, ' ');
|
|
28
|
+
const colorCode = (
|
|
29
|
+
entry.level === 'error' ? '\x1b[31m' :
|
|
30
|
+
entry.level === 'warn' ? '\x1b[33m' :
|
|
31
|
+
entry.level === 'debug' ? '\x1b[36m' : ''
|
|
32
|
+
);
|
|
33
|
+
const reset = '\x1b[0m';
|
|
34
|
+
|
|
35
|
+
const coloredLevel = colorCode + pad(entry.level.toUpperCase()) + reset;
|
|
36
|
+
const contextPart = entry.context ? ` [${entry.context}]` : '';
|
|
37
|
+
|
|
38
|
+
let output = `${timestamp} ${coloredLevel}${contextPart} ${entry.message}`;
|
|
39
|
+
|
|
40
|
+
if (entry.data) {
|
|
41
|
+
output += '\n' + JSON.stringify(entry.data, null, 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
debug(message: string, data?: Record<string, unknown>): void {
|
|
48
|
+
if (this.shouldLog('debug')) {
|
|
49
|
+
console.log(this.formatEntry({
|
|
50
|
+
timestamp: new Date(),
|
|
51
|
+
level: 'debug',
|
|
52
|
+
message: this.context ? `[${this.context}] ${message}` : message,
|
|
53
|
+
data,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
info(message: string, data?: Record<string, unknown>): void {
|
|
59
|
+
if (this.shouldLog('info')) {
|
|
60
|
+
console.log(this.formatEntry({
|
|
61
|
+
timestamp: new Date(),
|
|
62
|
+
level: 'info',
|
|
63
|
+
message: this.context ? `[${this.context}] ${message}` : message,
|
|
64
|
+
data,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
warn(message: string, data?: Record<string, unknown>): void {
|
|
70
|
+
if (this.shouldLog('warn')) {
|
|
71
|
+
console.warn(this.formatEntry({
|
|
72
|
+
timestamp: new Date(),
|
|
73
|
+
level: 'warn',
|
|
74
|
+
message: this.context ? `[${this.context}] ${message}` : message,
|
|
75
|
+
data,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
error(message: string, data?: Record<string, unknown>): void {
|
|
81
|
+
if (this.shouldLog('error')) {
|
|
82
|
+
console.error(this.formatEntry({
|
|
83
|
+
timestamp: new Date(),
|
|
84
|
+
level: 'error',
|
|
85
|
+
message: this.context ? `[${this.context}] ${message}` : message,
|
|
86
|
+
data,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
child(context: string): Logger {
|
|
92
|
+
return new Logger(this.minLevel, this.context ? `${this.context}.${context}` : context);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const logger = new Logger('info');
|