@wwkit/freetoken 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 +21 -0
- package/README.md +96 -0
- package/cli/helpers/args.js +48 -0
- package/cli/index.js +374 -0
- package/cli/pid-manager.js +87 -0
- package/client/index.html +25 -0
- package/client/public/favicon.svg +1 -0
- package/client/public/icons.svg +24 -0
- package/client/src/App.vue +136 -0
- package/client/src/assets/vite.svg +1 -0
- package/client/src/assets/vue.svg +1 -0
- package/client/src/components/ChatBox.vue +320 -0
- package/client/src/components/CheckList.vue +52 -0
- package/client/src/components/CodeBlock.vue +99 -0
- package/client/src/components/DetailBlock.vue +47 -0
- package/client/src/components/HelloWorld.vue +95 -0
- package/client/src/components/LangSwitch.vue +32 -0
- package/client/src/components/LinkList.vue +32 -0
- package/client/src/components/OsSwitch.vue +42 -0
- package/client/src/components/OsToggle.vue +15 -0
- package/client/src/components/PageHeader.vue +31 -0
- package/client/src/components/StepList.vue +92 -0
- package/client/src/composables/useClipboard.js +26 -0
- package/client/src/composables/useOs.js +22 -0
- package/client/src/data/docs/agent.js +1002 -0
- package/client/src/data/docs/auth.js +631 -0
- package/client/src/data/docs/builtin-tools.js +322 -0
- package/client/src/data/docs/chat.js +181 -0
- package/client/src/data/docs/index.js +9 -0
- package/client/src/data/docs/skills.js +396 -0
- package/client/src/data/docs/spec-conversion.js +1042 -0
- package/client/src/data/freeModels/bluesliu.js +97 -0
- package/client/src/data/freeModels/index.js +11 -0
- package/client/src/data/freeModels/nvidia.js +126 -0
- package/client/src/data/freeModels/openrouter.js +116 -0
- package/client/src/data/harness/claude.js +89 -0
- package/client/src/data/harness/codex.js +66 -0
- package/client/src/data/harness/dsh.js +86 -0
- package/client/src/data/harness/hermes.js +56 -0
- package/client/src/data/harness/index.js +22 -0
- package/client/src/data/harness/opencode.js +77 -0
- package/client/src/data/proxy/api-relay.js +53 -0
- package/client/src/data/proxy/builtin-proxy.js +54 -0
- package/client/src/data/proxy/cf-workers.js +67 -0
- package/client/src/data/proxy/ecs-forward.js +75 -0
- package/client/src/data/proxy/ecs-reverse.js +89 -0
- package/client/src/data/proxy/index.js +15 -0
- package/client/src/docs/claudecode.md +44 -0
- package/client/src/docs/codex.md +90 -0
- package/client/src/docs/hermesagent.md +42 -0
- package/client/src/docs/opencode.md +103 -0
- package/client/src/locales/en.js +436 -0
- package/client/src/locales/index.js +31 -0
- package/client/src/locales/zh-CN.js +461 -0
- package/client/src/main.js +16 -0
- package/client/src/router/index.js +76 -0
- package/client/src/style.css +15 -0
- package/client/src/views/AdminModelsView.vue +214 -0
- package/client/src/views/DocsView.vue +1604 -0
- package/client/src/views/FreeModelsView.vue +518 -0
- package/client/src/views/HarnessView.vue +314 -0
- package/client/src/views/HomeView.vue +147 -0
- package/client/src/views/ProxyView.vue +112 -0
- package/client/src/views/TokenMarketView.vue +26 -0
- package/client/vite.config.js +41 -0
- package/package.json +85 -0
- package/scripts/build-zip.sh +61 -0
- package/scripts/postinstall.js +7 -0
- package/server/src/config/targets.json +1 -0
- package/server/src/index.js +52 -0
- package/server/src/lib/coding-test.js +215 -0
- package/server/src/lib/database.js +353 -0
- package/server/src/lib/run-test.js +15 -0
- package/server/src/lib/scheduler.js +21 -0
- package/server/src/lib/tester.js +310 -0
- package/server/src/routes/admin.js +48 -0
- package/server/src/routes/proxy.js +64 -0
- package/server/src/routes/speed.js +87 -0
- package/src/config.js +45 -0
- package/src/config.json5 +27 -0
- package/src/index.js +10 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import initSqlJs from 'sql.js'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
7
|
+
const __dirname = path.dirname(__filename)
|
|
8
|
+
|
|
9
|
+
const dbDir = path.join(__dirname, '../../db')
|
|
10
|
+
const dbPath = path.join(dbDir, 'speed.db')
|
|
11
|
+
const targetsPath = path.join(__dirname, '../config/targets.json')
|
|
12
|
+
|
|
13
|
+
if (!existsSync(dbDir)) {
|
|
14
|
+
mkdirSync(dbDir, { recursive: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let db = null
|
|
18
|
+
let SQL = null
|
|
19
|
+
|
|
20
|
+
export async function initDatabase() {
|
|
21
|
+
if (db) return db
|
|
22
|
+
|
|
23
|
+
SQL = await initSqlJs()
|
|
24
|
+
|
|
25
|
+
if (existsSync(dbPath)) {
|
|
26
|
+
const buffer = readFileSync(dbPath)
|
|
27
|
+
db = new SQL.Database(buffer)
|
|
28
|
+
} else {
|
|
29
|
+
db = new SQL.Database()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 模型信息表
|
|
33
|
+
db.run(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS models (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
provider TEXT NOT NULL,
|
|
37
|
+
model TEXT NOT NULL UNIQUE,
|
|
38
|
+
base_url TEXT NOT NULL,
|
|
39
|
+
api_key_env TEXT,
|
|
40
|
+
enable_full_test INTEGER DEFAULT 0,
|
|
41
|
+
context_window INTEGER,
|
|
42
|
+
description TEXT,
|
|
43
|
+
created_at TEXT
|
|
44
|
+
)
|
|
45
|
+
`)
|
|
46
|
+
|
|
47
|
+
// 兼容旧表:如果列不存在则添加
|
|
48
|
+
try {
|
|
49
|
+
db.run(`ALTER TABLE models ADD COLUMN enable_full_test INTEGER DEFAULT 0`)
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// 列已存在,忽略
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 测试结果表
|
|
55
|
+
db.run(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS test_results (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
model_id INTEGER,
|
|
59
|
+
provider TEXT NOT NULL,
|
|
60
|
+
model TEXT NOT NULL,
|
|
61
|
+
available INTEGER DEFAULT 0,
|
|
62
|
+
ttft_ms INTEGER,
|
|
63
|
+
e2e_ms INTEGER,
|
|
64
|
+
tokens_generated INTEGER,
|
|
65
|
+
generation_ms INTEGER,
|
|
66
|
+
throughput REAL,
|
|
67
|
+
coding_score INTEGER,
|
|
68
|
+
coding_passed INTEGER,
|
|
69
|
+
coding_total INTEGER,
|
|
70
|
+
error_msg TEXT,
|
|
71
|
+
tested_at TEXT NOT NULL,
|
|
72
|
+
FOREIGN KEY (model_id) REFERENCES models(id)
|
|
73
|
+
)
|
|
74
|
+
`)
|
|
75
|
+
|
|
76
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_test_results_model ON test_results(model)`)
|
|
77
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_test_results_tested_at ON test_results(tested_at)`)
|
|
78
|
+
|
|
79
|
+
saveDatabase()
|
|
80
|
+
return db
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 初始化所有模型到数据库
|
|
84
|
+
export function initModelsFromConfig() {
|
|
85
|
+
if (!db) throw new Error('Database not initialized')
|
|
86
|
+
|
|
87
|
+
// 读取配置文件
|
|
88
|
+
if (!existsSync(targetsPath)) {
|
|
89
|
+
console.log('No targets config found')
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const raw = readFileSync(targetsPath, 'utf-8')
|
|
94
|
+
const targets = JSON.parse(raw)
|
|
95
|
+
|
|
96
|
+
let count = 0
|
|
97
|
+
for (const target of targets) {
|
|
98
|
+
for (const modelConfig of target.models) {
|
|
99
|
+
// 检查是否已存在
|
|
100
|
+
const existing = db.exec(`SELECT id FROM models WHERE model = ?`, [modelConfig.name])
|
|
101
|
+
|
|
102
|
+
if (existing.length === 0 || existing[0].values.length === 0) {
|
|
103
|
+
db.run(`
|
|
104
|
+
INSERT INTO models (provider, model, base_url, api_key_env, enable_full_test, created_at)
|
|
105
|
+
VALUES (?, ?, ?, ?, 0, ?)
|
|
106
|
+
`, [
|
|
107
|
+
target.provider,
|
|
108
|
+
modelConfig.name,
|
|
109
|
+
target.baseUrl,
|
|
110
|
+
modelConfig.apiKeyEnv || null,
|
|
111
|
+
new Date().toISOString()
|
|
112
|
+
])
|
|
113
|
+
count++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (count > 0) {
|
|
119
|
+
saveDatabase()
|
|
120
|
+
console.log(`Initialized ${count} models from config`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function saveDatabase() {
|
|
125
|
+
if (db) {
|
|
126
|
+
const data = db.export()
|
|
127
|
+
const buffer = Buffer.from(data)
|
|
128
|
+
writeFileSync(dbPath, buffer)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 插入或更新模型
|
|
133
|
+
export function upsertModel(modelInfo) {
|
|
134
|
+
if (!db) throw new Error('Database not initialized')
|
|
135
|
+
|
|
136
|
+
const existing = db.exec(`SELECT id FROM models WHERE model = ?`, [modelInfo.model])
|
|
137
|
+
|
|
138
|
+
if (existing.length > 0 && existing[0].values.length > 0) {
|
|
139
|
+
db.run(`
|
|
140
|
+
UPDATE models SET
|
|
141
|
+
provider = ?, base_url = ?, api_key_env = ?, enable_full_test = ?, context_window = ?, description = ?
|
|
142
|
+
WHERE model = ?
|
|
143
|
+
`, [
|
|
144
|
+
modelInfo.provider,
|
|
145
|
+
modelInfo.baseUrl,
|
|
146
|
+
modelInfo.apiKeyEnv || null,
|
|
147
|
+
modelInfo.enableFullTest !== undefined ? (modelInfo.enableFullTest ? 1 : 0) : 0,
|
|
148
|
+
modelInfo.contextWindow || null,
|
|
149
|
+
modelInfo.description || null,
|
|
150
|
+
modelInfo.model
|
|
151
|
+
])
|
|
152
|
+
return existing[0].values[0][0]
|
|
153
|
+
} else {
|
|
154
|
+
db.run(`
|
|
155
|
+
INSERT INTO models (provider, model, base_url, api_key_env, enable_full_test, context_window, description, created_at)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
157
|
+
`, [
|
|
158
|
+
modelInfo.provider,
|
|
159
|
+
modelInfo.model,
|
|
160
|
+
modelInfo.baseUrl,
|
|
161
|
+
modelInfo.apiKeyEnv || null,
|
|
162
|
+
modelInfo.enableFullTest !== undefined ? (modelInfo.enableFullTest ? 1 : 0) : 0,
|
|
163
|
+
modelInfo.contextWindow || null,
|
|
164
|
+
modelInfo.description || null,
|
|
165
|
+
new Date().toISOString()
|
|
166
|
+
])
|
|
167
|
+
const result = db.exec(`SELECT last_insert_rowid()`)
|
|
168
|
+
return result[0].values[0][0]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 插入测试结果
|
|
173
|
+
export function insertTestResult(result) {
|
|
174
|
+
if (!db) throw new Error('Database not initialized')
|
|
175
|
+
|
|
176
|
+
const toNull = (v) => v === undefined ? null : v
|
|
177
|
+
|
|
178
|
+
db.run(`
|
|
179
|
+
INSERT INTO test_results (
|
|
180
|
+
model_id, provider, model, available,
|
|
181
|
+
ttft_ms, e2e_ms, tokens_generated, generation_ms, throughput,
|
|
182
|
+
coding_score, coding_passed, coding_total, error_msg, tested_at
|
|
183
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
184
|
+
`, [
|
|
185
|
+
toNull(result.modelId),
|
|
186
|
+
result.provider,
|
|
187
|
+
result.model,
|
|
188
|
+
result.available ? 1 : 0,
|
|
189
|
+
toNull(result.ttftMs),
|
|
190
|
+
toNull(result.e2eMs),
|
|
191
|
+
toNull(result.tokensGenerated),
|
|
192
|
+
toNull(result.generationMs),
|
|
193
|
+
toNull(result.throughput),
|
|
194
|
+
toNull(result.codingScore),
|
|
195
|
+
toNull(result.codingPassed),
|
|
196
|
+
toNull(result.codingTotal),
|
|
197
|
+
toNull(result.errorMsg),
|
|
198
|
+
result.testedAt
|
|
199
|
+
])
|
|
200
|
+
|
|
201
|
+
saveDatabase()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 获取最新测试结果
|
|
205
|
+
export function getLatestResults() {
|
|
206
|
+
if (!db) throw new Error('Database not initialized')
|
|
207
|
+
|
|
208
|
+
const results = db.exec(`
|
|
209
|
+
SELECT
|
|
210
|
+
tr.provider, tr.model, tr.available,
|
|
211
|
+
tr.ttft_ms as ttftMs, tr.e2e_ms as e2eMs,
|
|
212
|
+
tr.tokens_generated as tokensGenerated, tr.generation_ms as generationMs,
|
|
213
|
+
tr.throughput,
|
|
214
|
+
tr.coding_score as codingScore, tr.coding_passed as codingPassed, tr.coding_total as codingTotal,
|
|
215
|
+
tr.error_msg as errorMsg, tr.tested_at as testedAt,
|
|
216
|
+
m.context_window as contextWindow, m.description, m.api_key_env as apiKeyEnv
|
|
217
|
+
FROM test_results tr
|
|
218
|
+
LEFT JOIN models m ON tr.model_id = m.id
|
|
219
|
+
WHERE tr.id IN (
|
|
220
|
+
SELECT MAX(id) FROM test_results GROUP BY model
|
|
221
|
+
)
|
|
222
|
+
ORDER BY tr.provider, tr.model
|
|
223
|
+
`)
|
|
224
|
+
|
|
225
|
+
if (results.length === 0) return []
|
|
226
|
+
|
|
227
|
+
const columns = results[0].columns
|
|
228
|
+
return results[0].values.map(row => {
|
|
229
|
+
const obj = {}
|
|
230
|
+
columns.forEach((col, i) => {
|
|
231
|
+
obj[col] = row[i]
|
|
232
|
+
})
|
|
233
|
+
return obj
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 获取历史记录
|
|
238
|
+
export function getHistory(model, limit = 20) {
|
|
239
|
+
if (!db) throw new Error('Database not initialized')
|
|
240
|
+
|
|
241
|
+
const results = db.exec(`
|
|
242
|
+
SELECT
|
|
243
|
+
available, ttft_ms as ttftMs, e2e_ms as e2eMs,
|
|
244
|
+
throughput, coding_score as codingScore,
|
|
245
|
+
tested_at as testedAt
|
|
246
|
+
FROM test_results
|
|
247
|
+
WHERE model = ?
|
|
248
|
+
ORDER BY tested_at DESC
|
|
249
|
+
LIMIT ?
|
|
250
|
+
`, [model, limit])
|
|
251
|
+
|
|
252
|
+
if (results.length === 0) return []
|
|
253
|
+
|
|
254
|
+
const columns = results[0].columns
|
|
255
|
+
return results[0].values.map(row => {
|
|
256
|
+
const obj = {}
|
|
257
|
+
columns.forEach((col, i) => {
|
|
258
|
+
obj[col] = row[i]
|
|
259
|
+
})
|
|
260
|
+
return obj
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 获取所有模型
|
|
265
|
+
export function getAllModels() {
|
|
266
|
+
if (!db) throw new Error('Database not initialized')
|
|
267
|
+
|
|
268
|
+
const results = db.exec(`
|
|
269
|
+
SELECT id, provider, model, base_url as baseUrl, api_key_env as apiKeyEnv, enable_full_test as enableFullTest, context_window as contextWindow, description
|
|
270
|
+
FROM models
|
|
271
|
+
ORDER BY provider, model
|
|
272
|
+
`)
|
|
273
|
+
|
|
274
|
+
if (results.length === 0) return []
|
|
275
|
+
|
|
276
|
+
const columns = results[0].columns
|
|
277
|
+
return results[0].values.map(row => {
|
|
278
|
+
const obj = {}
|
|
279
|
+
columns.forEach((col, i) => {
|
|
280
|
+
obj[col] = row[i]
|
|
281
|
+
})
|
|
282
|
+
return obj
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 更新模型
|
|
287
|
+
export function updateModel(id, modelInfo) {
|
|
288
|
+
if (!db) throw new Error('Database not initialized')
|
|
289
|
+
|
|
290
|
+
db.run(`
|
|
291
|
+
UPDATE models SET
|
|
292
|
+
provider = ?, model = ?, base_url = ?, api_key_env = ?, enable_full_test = ?, context_window = ?, description = ?
|
|
293
|
+
WHERE id = ?
|
|
294
|
+
`, [
|
|
295
|
+
modelInfo.provider,
|
|
296
|
+
modelInfo.model,
|
|
297
|
+
modelInfo.baseUrl,
|
|
298
|
+
modelInfo.apiKeyEnv || null,
|
|
299
|
+
modelInfo.enableFullTest ? 1 : 0,
|
|
300
|
+
modelInfo.contextWindow || null,
|
|
301
|
+
modelInfo.description || null,
|
|
302
|
+
id
|
|
303
|
+
])
|
|
304
|
+
|
|
305
|
+
saveDatabase()
|
|
306
|
+
return { id, ...modelInfo }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 删除模型
|
|
310
|
+
export function deleteModel(id) {
|
|
311
|
+
if (!db) throw new Error('Database not initialized')
|
|
312
|
+
|
|
313
|
+
db.run(`DELETE FROM models WHERE id = ?`, [id])
|
|
314
|
+
saveDatabase()
|
|
315
|
+
return { id }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 添加模型
|
|
319
|
+
export function addModel(modelInfo) {
|
|
320
|
+
if (!db) throw new Error('Database not initialized')
|
|
321
|
+
|
|
322
|
+
db.run(`
|
|
323
|
+
INSERT INTO models (provider, model, base_url, api_key_env, enable_full_test, context_window, description, created_at)
|
|
324
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
325
|
+
`, [
|
|
326
|
+
modelInfo.provider,
|
|
327
|
+
modelInfo.model,
|
|
328
|
+
modelInfo.baseUrl,
|
|
329
|
+
modelInfo.apiKeyEnv || null,
|
|
330
|
+
modelInfo.enableFullTest ? 1 : 0,
|
|
331
|
+
modelInfo.contextWindow || null,
|
|
332
|
+
modelInfo.description || null,
|
|
333
|
+
new Date().toISOString()
|
|
334
|
+
])
|
|
335
|
+
|
|
336
|
+
const result = db.exec(`SELECT last_insert_rowid()`)
|
|
337
|
+
const id = result[0].values[0][0]
|
|
338
|
+
saveDatabase()
|
|
339
|
+
return { id, ...modelInfo }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export default {
|
|
343
|
+
initDatabase,
|
|
344
|
+
initModelsFromConfig,
|
|
345
|
+
upsertModel,
|
|
346
|
+
insertTestResult,
|
|
347
|
+
getLatestResults,
|
|
348
|
+
getHistory,
|
|
349
|
+
getAllModels,
|
|
350
|
+
updateModel,
|
|
351
|
+
deleteModel,
|
|
352
|
+
addModel
|
|
353
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import dotenv from 'dotenv'
|
|
2
|
+
import { runSpeedTest } from './tester.js'
|
|
3
|
+
|
|
4
|
+
dotenv.config()
|
|
5
|
+
|
|
6
|
+
console.log('Starting manual speed test...')
|
|
7
|
+
runSpeedTest()
|
|
8
|
+
.then(results => {
|
|
9
|
+
console.log(`\nSpeed test completed. ${results.length} models tested.`)
|
|
10
|
+
process.exit(0)
|
|
11
|
+
})
|
|
12
|
+
.catch(err => {
|
|
13
|
+
console.error('Speed test failed:', err)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import cron from 'node-cron'
|
|
2
|
+
import { runSpeedTest } from './tester.js'
|
|
3
|
+
import { getSpeedTestConfig } from '../../../src/config.js'
|
|
4
|
+
|
|
5
|
+
export function initScheduler() {
|
|
6
|
+
const speedTestConfig = getSpeedTestConfig()
|
|
7
|
+
const schedule = speedTestConfig.cron || '0 9 * * *'
|
|
8
|
+
const runOnStart = speedTestConfig.runOnStart !== false
|
|
9
|
+
|
|
10
|
+
console.log(`Speed test scheduler initialized: ${schedule}`)
|
|
11
|
+
|
|
12
|
+
if (runOnStart) {
|
|
13
|
+
console.log('Running initial speed test...')
|
|
14
|
+
runSpeedTest().catch(err => console.error('Speed test failed:', err))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
cron.schedule(schedule, () => {
|
|
18
|
+
console.log('Running scheduled speed test...')
|
|
19
|
+
runSpeedTest().catch(err => console.error('Speed test failed:', err))
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import { initDatabase, upsertModel, insertTestResult, getLatestResults, getHistory, getAllModels } from './database.js'
|
|
3
|
+
import { testCodingAbility } from './coding-test.js'
|
|
4
|
+
import { readFileSync } from 'fs'
|
|
5
|
+
import { dirname, join } from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
|
|
11
|
+
const TIMEOUT_MS = 30000
|
|
12
|
+
const THROUGHPUT_TIMEOUT_MS = 120000
|
|
13
|
+
|
|
14
|
+
function loadTargets() {
|
|
15
|
+
const targetsPath = join(__dirname, '../config/targets.json')
|
|
16
|
+
const raw = readFileSync(targetsPath, 'utf-8')
|
|
17
|
+
return JSON.parse(raw)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 从环境变量获取 API key
|
|
21
|
+
function getApiKey(envVarName) {
|
|
22
|
+
if (!envVarName) return null
|
|
23
|
+
return process.env[envVarName] || null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 测试可用性
|
|
27
|
+
async function testAvailability(baseUrl, apiKey, model) {
|
|
28
|
+
try {
|
|
29
|
+
const response = await axios({
|
|
30
|
+
method: 'POST',
|
|
31
|
+
url: `${baseUrl}/chat/completions`,
|
|
32
|
+
headers: {
|
|
33
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
34
|
+
'Content-Type': 'application/json'
|
|
35
|
+
},
|
|
36
|
+
data: {
|
|
37
|
+
model,
|
|
38
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
39
|
+
max_tokens: 10
|
|
40
|
+
},
|
|
41
|
+
timeout: TIMEOUT_MS
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const message = response.data.choices?.[0]?.message || {}
|
|
45
|
+
const content = message.content || message.reasoning_content || ''
|
|
46
|
+
return {
|
|
47
|
+
available: content.length > 0,
|
|
48
|
+
errorMsg: content.length > 0 ? null : `${response.status} Empty response`
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.response) {
|
|
52
|
+
const status = error.response.status
|
|
53
|
+
const statusText = error.response.statusText
|
|
54
|
+
const apiMsg = error.response?.data?.error?.message
|
|
55
|
+
return {
|
|
56
|
+
available: false,
|
|
57
|
+
errorMsg: `${status} ${statusText}${apiMsg ? ': ' + apiMsg : ''}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
available: false,
|
|
62
|
+
errorMsg: error.message
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 测试响应速度 (TTFT + E2E)
|
|
68
|
+
async function testLatency(baseUrl, apiKey, model) {
|
|
69
|
+
const startTime = Date.now()
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const response = await axios({
|
|
73
|
+
method: 'POST',
|
|
74
|
+
url: `${baseUrl}/chat/completions`,
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
77
|
+
'Content-Type': 'application/json'
|
|
78
|
+
},
|
|
79
|
+
data: {
|
|
80
|
+
model,
|
|
81
|
+
messages: [{ role: 'user', content: '请用50字介绍人工智能' }],
|
|
82
|
+
max_tokens: 100,
|
|
83
|
+
stream: true
|
|
84
|
+
},
|
|
85
|
+
responseType: 'stream',
|
|
86
|
+
timeout: TIMEOUT_MS
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let firstChunkTime = null
|
|
91
|
+
let chunks = []
|
|
92
|
+
|
|
93
|
+
response.data.on('data', (chunk) => {
|
|
94
|
+
const text = chunk.toString()
|
|
95
|
+
chunks.push(chunk)
|
|
96
|
+
|
|
97
|
+
if (!firstChunkTime && text.includes('delta') && text.includes('content')) {
|
|
98
|
+
firstChunkTime = Date.now()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
response.data.on('error', (err) => {
|
|
103
|
+
reject({
|
|
104
|
+
ttftMs: null,
|
|
105
|
+
e2eMs: null,
|
|
106
|
+
errorMsg: err.message
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
response.data.on('end', () => {
|
|
111
|
+
const endTime = Date.now()
|
|
112
|
+
resolve({
|
|
113
|
+
ttftMs: firstChunkTime ? firstChunkTime - startTime : null,
|
|
114
|
+
e2eMs: endTime - startTime,
|
|
115
|
+
errorMsg: null
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
ttftMs: null,
|
|
122
|
+
e2eMs: null,
|
|
123
|
+
errorMsg: error.message
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 测试吞吐速度 (tokens/s)
|
|
129
|
+
async function testThroughput(baseUrl, apiKey, model) {
|
|
130
|
+
const startTime = Date.now()
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const response = await axios({
|
|
134
|
+
method: 'POST',
|
|
135
|
+
url: `${baseUrl}/chat/completions`,
|
|
136
|
+
headers: {
|
|
137
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
138
|
+
'Content-Type': 'application/json'
|
|
139
|
+
},
|
|
140
|
+
data: {
|
|
141
|
+
model,
|
|
142
|
+
messages: [{ role: 'user', content: '请详细介绍人工智能的发展历史,包括重要里程碑和代表人物,至少500字。' }],
|
|
143
|
+
max_tokens: 800,
|
|
144
|
+
stream: false
|
|
145
|
+
},
|
|
146
|
+
timeout: THROUGHPUT_TIMEOUT_MS
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const endTime = Date.now()
|
|
150
|
+
const message = response.data.choices?.[0]?.message || {}
|
|
151
|
+
const content = message.content || message.reasoning_content || ''
|
|
152
|
+
const usage = response.data.usage || {}
|
|
153
|
+
|
|
154
|
+
// 计算 token 数
|
|
155
|
+
const tokens = usage.total_tokens || Math.round(content.length / 3.5)
|
|
156
|
+
const generationMs = endTime - startTime
|
|
157
|
+
const throughput = generationMs > 0 ? (tokens / (generationMs / 1000)).toFixed(1) : 0
|
|
158
|
+
|
|
159
|
+
console.log(` Tokens: ${tokens}, GenMs: ${generationMs}, ContentLen: ${content.length}`)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
tokensGenerated: tokens,
|
|
163
|
+
generationMs,
|
|
164
|
+
throughput: parseFloat(throughput),
|
|
165
|
+
errorMsg: null
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.log(` 吞吐测试错误: ${error.message}`)
|
|
169
|
+
return {
|
|
170
|
+
tokensGenerated: 0,
|
|
171
|
+
generationMs: 0,
|
|
172
|
+
throughput: 0,
|
|
173
|
+
errorMsg: error.message
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 执行完整测试
|
|
179
|
+
export async function runSpeedTest() {
|
|
180
|
+
await initDatabase()
|
|
181
|
+
|
|
182
|
+
const targets = loadTargets()
|
|
183
|
+
// 从数据库获取模型配置(含 enable_full_test)
|
|
184
|
+
const dbModels = getAllModels()
|
|
185
|
+
const modelConfigMap = {}
|
|
186
|
+
for (const m of dbModels) {
|
|
187
|
+
modelConfigMap[m.model] = m
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const results = []
|
|
191
|
+
const testedAt = new Date().toISOString()
|
|
192
|
+
|
|
193
|
+
for (const target of targets) {
|
|
194
|
+
console.log(`\nTesting ${target.provider}...`)
|
|
195
|
+
|
|
196
|
+
for (const modelConfig of target.models) {
|
|
197
|
+
const modelName = modelConfig.name
|
|
198
|
+
const apiKey = getApiKey(modelConfig.apiKeyEnv)
|
|
199
|
+
|
|
200
|
+
if (!apiKey) {
|
|
201
|
+
console.log(` Skipping ${modelName}: API key not configured (${modelConfig.apiKeyEnv})`)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(` Model: ${modelName}`)
|
|
206
|
+
|
|
207
|
+
// 从数据库获取 enable_full_test 配置
|
|
208
|
+
const dbModel = modelConfigMap[modelName]
|
|
209
|
+
const enableFullTest = dbModel?.enableFullTest === 1
|
|
210
|
+
|
|
211
|
+
// 1. 保存模型信息
|
|
212
|
+
const modelId = upsertModel({
|
|
213
|
+
provider: target.provider,
|
|
214
|
+
model: modelName,
|
|
215
|
+
baseUrl: target.baseUrl,
|
|
216
|
+
apiKeyEnv: modelConfig.apiKeyEnv,
|
|
217
|
+
enableFullTest,
|
|
218
|
+
contextWindow: dbModel?.contextWindow || null,
|
|
219
|
+
description: dbModel?.description || null
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// 2. 测试可用性
|
|
223
|
+
console.log(` [1/1] 可用性测试...`)
|
|
224
|
+
const availResult = await testAvailability(target.baseUrl, apiKey, modelName)
|
|
225
|
+
|
|
226
|
+
if (!availResult.available) {
|
|
227
|
+
insertTestResult({
|
|
228
|
+
modelId,
|
|
229
|
+
provider: target.provider,
|
|
230
|
+
model: modelName,
|
|
231
|
+
available: false,
|
|
232
|
+
errorMsg: availResult.errorMsg,
|
|
233
|
+
testedAt
|
|
234
|
+
})
|
|
235
|
+
results.push({ model: modelName, available: false, errorMsg: availResult.errorMsg })
|
|
236
|
+
console.log(` ❌ 不可用: ${availResult.errorMsg}`)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(` ✅ 可用`)
|
|
241
|
+
|
|
242
|
+
// 如果未开启完整测试,只保存可用性结果
|
|
243
|
+
if (!enableFullTest) {
|
|
244
|
+
console.log(` ⏭️ 跳过完整测试 (enable_full_test=0)`)
|
|
245
|
+
const result = {
|
|
246
|
+
modelId,
|
|
247
|
+
provider: target.provider,
|
|
248
|
+
model: modelName,
|
|
249
|
+
available: true,
|
|
250
|
+
testedAt
|
|
251
|
+
}
|
|
252
|
+
insertTestResult(result)
|
|
253
|
+
results.push(result)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 3. 测试响应速度
|
|
258
|
+
console.log(` [2/4] 响应速度测试...`)
|
|
259
|
+
const latencyResult = await testLatency(target.baseUrl, apiKey, modelName)
|
|
260
|
+
console.log(` TTFT: ${latencyResult.ttftMs}ms, E2E: ${latencyResult.e2eMs}ms`)
|
|
261
|
+
|
|
262
|
+
// 4. 测试吞吐速度
|
|
263
|
+
console.log(` [3/4] 吞吐速度测试...`)
|
|
264
|
+
const throughputResult = await testThroughput(target.baseUrl, apiKey, modelName)
|
|
265
|
+
console.log(` Throughput: ${throughputResult.throughput} tokens/s`)
|
|
266
|
+
|
|
267
|
+
// 5. 测试编码能力
|
|
268
|
+
console.log(` [4/4] 编码能力测试...`)
|
|
269
|
+
const codingResult = await testCodingAbility(target.baseUrl, apiKey, modelName)
|
|
270
|
+
console.log(` Coding: ${codingResult.passed}/${codingResult.total} passed, Score: ${codingResult.score}`)
|
|
271
|
+
|
|
272
|
+
// 6. 保存结果
|
|
273
|
+
const result = {
|
|
274
|
+
modelId,
|
|
275
|
+
provider: target.provider,
|
|
276
|
+
model: modelName,
|
|
277
|
+
available: true,
|
|
278
|
+
ttftMs: latencyResult.ttftMs,
|
|
279
|
+
e2eMs: latencyResult.e2eMs,
|
|
280
|
+
tokensGenerated: throughputResult.tokensGenerated,
|
|
281
|
+
generationMs: throughputResult.generationMs,
|
|
282
|
+
throughput: throughputResult.throughput,
|
|
283
|
+
codingScore: codingResult.score,
|
|
284
|
+
codingPassed: codingResult.passed,
|
|
285
|
+
codingTotal: codingResult.total,
|
|
286
|
+
testedAt
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
insertTestResult(result)
|
|
290
|
+
results.push(result)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return results
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export { getLatestResults, getHistory }
|
|
298
|
+
|
|
299
|
+
export async function getTargets() {
|
|
300
|
+
const targets = loadTargets()
|
|
301
|
+
return targets.map(t => ({
|
|
302
|
+
provider: t.provider,
|
|
303
|
+
baseUrl: t.baseUrl,
|
|
304
|
+
models: t.models.map(m => ({
|
|
305
|
+
name: m.name,
|
|
306
|
+
apiKeyEnv: m.apiKeyEnv,
|
|
307
|
+
hasApiKey: !!getApiKey(m.apiKeyEnv)
|
|
308
|
+
}))
|
|
309
|
+
}))
|
|
310
|
+
}
|