dsh-router-core 0.3.3 → 0.3.4
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/README.md +1 -1
- package/docs/suppliers.md +71 -30
- package/lib/client-registry.js +7 -3
- package/lib/client.js +7 -3
- package/lib/index.js +140 -49
- package/lib/types/router/index.d.ts +23 -5
- package/lib/types/router/types.d.ts +0 -2
- package/lib/types/supplier-config.d.ts +23 -0
- package/lib/types/suppliers/contract.d.ts +9 -6
- package/package.json +1 -1
- package/src/client/SupplierDetail.tsx +7 -2
- package/src/router/index.ts +88 -28
- package/src/router/models-cache.test.ts +137 -2
- package/src/router/types.ts +0 -2
- package/src/supplier-config.test.ts +98 -0
- package/src/supplier-config.ts +60 -1
- package/src/suppliers/contract.ts +9 -9
- package/src/suppliers/loader.test.ts +118 -0
- package/src/suppliers/loader.ts +19 -4
- package/src/suppliers/nvidia/plugin.ts +12 -15
- package/src/suppliers/opencode/plugin.ts +10 -13
- package/src/suppliers/openrouter/plugin.ts +12 -15
- package/src/suppliers/registry.ts +4 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 积分回灌(loader 层)测试 —— 锁住 2026-09-03 那个 bug:
|
|
3
|
+
*
|
|
4
|
+
* codebuddy 插件只在内存里缓存积分,重启后 status() 报 0(当时还没有 -1 哨兵),
|
|
5
|
+
* 核心又没有持久化 → 面板每次重启都显示 0 积分,而 traework 因为自己落盘
|
|
6
|
+
* state.json 所以有值。现在由核心统一持久化:插件报 -1 就顶上次的缓存。
|
|
7
|
+
*
|
|
8
|
+
* 这里测的是 wrapModule 包装后的 status()(面板真正读的那个)。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdtempSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { wrapModule } from './loader.ts'
|
|
16
|
+
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
17
|
+
import type { CredentialStore } from '../credential-store.ts'
|
|
18
|
+
import type { SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
|
|
19
|
+
import type { ChatRequest } from '../router/types.ts'
|
|
20
|
+
|
|
21
|
+
const UNKNOWN = -1
|
|
22
|
+
|
|
23
|
+
/** 造一个 env(真实 SupplierConfigStore 落临时目录,CredentialStore 用不着的空壳)。 */
|
|
24
|
+
function env(): { env: Parameters<typeof wrapModule>[1]; store: SupplierConfigStore; file: string } {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-hydrate-'))
|
|
26
|
+
const stateFile = join(dir, 'state.json')
|
|
27
|
+
const store = new SupplierConfigStore(stateFile)
|
|
28
|
+
return {
|
|
29
|
+
env: { dataDir: dir, log: () => {}, store, credentials: {} as CredentialStore },
|
|
30
|
+
store,
|
|
31
|
+
file: join(dir, 'supplier-config.json'),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 造一个插件:第 N 次 status() 报什么由脚本决定(模拟「重启后第一次拿不到,
|
|
37
|
+
* 异步拉完才有值」)。
|
|
38
|
+
*/
|
|
39
|
+
function plugin(id: string, script: Array<SupplierAccountNow[]>): SupplierModule & { calls: () => number } {
|
|
40
|
+
let n = 0
|
|
41
|
+
return {
|
|
42
|
+
calls: () => n,
|
|
43
|
+
id,
|
|
44
|
+
name: id,
|
|
45
|
+
// 脚本放完就停在最后一帧(模拟「第一次还没拉到 → 异步拉完有值」)
|
|
46
|
+
status: () => ({ id, name: id, accounts: script[Math.min(n++, script.length - 1)]! }),
|
|
47
|
+
listModels: () => [],
|
|
48
|
+
getAlias: () => id,
|
|
49
|
+
chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
|
|
50
|
+
dispose: () => {},
|
|
51
|
+
} as unknown as SupplierModule & { calls: () => number }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const acct = (uid: string, credits: number): SupplierAccountNow => ({ uid, credits, state: 'ok' })
|
|
55
|
+
|
|
56
|
+
test('插件报 -1(刚重启还没拉到)→ 顶上次缓存,面板不显示 0', () => {
|
|
57
|
+
const { env: e, store } = env()
|
|
58
|
+
// 先跑一轮拿到真值(模拟上次运行)
|
|
59
|
+
const first = wrapModule(plugin('codebuddy', [[acct('cb-1', 1500)]]), e, 'test')
|
|
60
|
+
assert.equal(first.supplier.status().accounts[0]?.credits, 1500)
|
|
61
|
+
|
|
62
|
+
// 再换个实例(模拟重启):插件报 -1,核心应该顶出 1500
|
|
63
|
+
const restarted = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
|
|
64
|
+
assert.equal(restarted.supplier.status().accounts[0]?.credits, 1500)
|
|
65
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 1500)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('插件报真值 → 缓存被刷新(不是一直吃旧值)', async () => {
|
|
69
|
+
const { env: e, store } = env()
|
|
70
|
+
store.putCredits('codebuddy', 'cb-1', 1500)
|
|
71
|
+
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 900)]]), e, 'test')
|
|
72
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, 900)
|
|
73
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 900)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('插件报 0(真用完了)→ 缓存被冲成 0,不被当成未知', async () => {
|
|
77
|
+
const { env: e, store } = env()
|
|
78
|
+
store.putCredits('codebuddy', 'cb-1', 500)
|
|
79
|
+
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 0)]]), e, 'test')
|
|
80
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, 0)
|
|
81
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('从没缓存过 + 插件报 -1 → 就是 -1(面板显示「积分未知」,不编 0)', async () => {
|
|
85
|
+
const { env: e } = env()
|
|
86
|
+
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)]]), e, 'test')
|
|
87
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, UNKNOWN)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('异步落地:第一帧 -1(顶旧值)→ 第二帧真值(覆盖)', async () => {
|
|
91
|
+
const { env: e, store } = env()
|
|
92
|
+
store.putCredits('codebuddy', 'cb-1', 800)
|
|
93
|
+
// 模拟插件 fire-and-forget:第一次 status() 报 -1,异步拉完第二次报真值
|
|
94
|
+
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', UNKNOWN)], [acct('cb-1', 1234)]]), e, 'test')
|
|
95
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, 800)
|
|
96
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, 1234)
|
|
97
|
+
assert.equal(store.getCredits('codebuddy', 'cb-1'), 1234)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('多账号各自独立回灌', async () => {
|
|
101
|
+
const { env: e, store } = env()
|
|
102
|
+
store.putCredits('codebuddy', 'cb-1', 100)
|
|
103
|
+
store.putCredits('codebuddy', 'cb-2', 200)
|
|
104
|
+
const loaded = wrapModule(
|
|
105
|
+
plugin('codebuddy', [[acct('cb-1', UNKNOWN), acct('cb-2', 250)]]),
|
|
106
|
+
e,
|
|
107
|
+
'test',
|
|
108
|
+
)
|
|
109
|
+
const by = new Map(loaded.supplier.status().accounts.map((a) => [a.uid, a.credits]))
|
|
110
|
+
assert.equal(by.get('cb-1'), 100) // 顶缓存
|
|
111
|
+
assert.equal(by.get('cb-2'), 250) // 用真值
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('不持久化(store fp 为空)时也不炸', async () => {
|
|
115
|
+
const e = { dataDir: '', log: () => {}, store: new SupplierConfigStore(''), credentials: {} as CredentialStore }
|
|
116
|
+
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 50)]]), e, 'test')
|
|
117
|
+
assert.equal(loaded.supplier.status().accounts[0]?.credits, 50)
|
|
118
|
+
})
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -36,6 +36,24 @@ function isFn(v: unknown): v is (...args: unknown[]) => unknown {
|
|
|
36
36
|
return typeof v === 'function'
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 积分回灌(核心统一持久化,插件不落盘)。
|
|
41
|
+
*
|
|
42
|
+
* 插件的积分是异步拉的:重启后第一次 status() 拿不到真值,报 `CREDITS_UNKNOWN`
|
|
43
|
+
* (-1)。这里做两件事:
|
|
44
|
+
* - 报了真值 → 写入 supplier-config.json(下次重启还在)
|
|
45
|
+
* - 报了 -1 → 用上次存的顶上,面板不会闪 0
|
|
46
|
+
*
|
|
47
|
+
* 插件不该自己把 0 当「未知」报上来 —— 0 是真值(用完了),核心分不清,
|
|
48
|
+
* 会把缓存冲成 0。这条是本层存在的理由(2026-09-03)。
|
|
49
|
+
*/
|
|
50
|
+
function hydrateCredits(supplierId: string, store: SupplierConfigStore, accounts: SupplierAccountNow[]): SupplierAccountNow[] {
|
|
51
|
+
return accounts.map((a) => {
|
|
52
|
+
const merged = store.putCredits(supplierId, a.uid, a.credits)
|
|
53
|
+
return merged === a.credits ? a : { ...a, credits: merged }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/** 从模块导出解析出供应商模块对象(支持 export default / 命名导出 / factory)。 */
|
|
40
58
|
function resolveModule(mod: Record<string, unknown>, file: string): SupplierModule {
|
|
41
59
|
const cand = (mod.default ?? mod) as Record<string, unknown>
|
|
@@ -108,7 +126,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
|
|
|
108
126
|
icon: (instance as { icon?: string }).icon,
|
|
109
127
|
status: (): SupplierStatus => {
|
|
110
128
|
const now = instance.status()
|
|
111
|
-
return { id: now.id, name: now.name, accounts: pool.decorate(now.accounts) }
|
|
129
|
+
return { id: now.id, name: now.name, accounts: pool.decorate(hydrateCredits(instance.id, env.store, now.accounts)) }
|
|
112
130
|
},
|
|
113
131
|
listModels: (force?: boolean): Promise<ModelInfo[]> | ModelInfo[] => instance.listModels(force),
|
|
114
132
|
// 通用能力:模型启用状态/自定义由核心 SupplierConfigStore 合并
|
|
@@ -129,9 +147,6 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
|
|
|
129
147
|
customModelIds: (): string[] => [...env.store.get(instance.id).custom],
|
|
130
148
|
accounts: (): SupplierAccountNow[] => instance.status().accounts,
|
|
131
149
|
chatOnce: (uid: string, req: ChatRequest): Promise<ChatOnceResult> => instance.chatOnce(uid, req),
|
|
132
|
-
lastError: isFn((instance as unknown as Record<string, unknown>).lastError)
|
|
133
|
-
? (): string | undefined => (instance as unknown as { lastError(): string | undefined }).lastError()
|
|
134
|
-
: undefined,
|
|
135
150
|
// 删除链接:数据删除是通用能力,凭证清理由供应商内部实现(js 契约不要求)
|
|
136
151
|
removeLink: isFn((instance as unknown as Record<string, unknown>).removeLink)
|
|
137
152
|
? (uid: string): Promise<boolean> => (instance as unknown as { removeLink(uid: string): Promise<boolean> }).removeLink(uid)
|
|
@@ -54,7 +54,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
54
54
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
55
55
|
let modelsCache: ModelInfo[] | undefined
|
|
56
56
|
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
57
|
-
let lastErr: string | undefined
|
|
58
57
|
function listKeys(): string[] {
|
|
59
58
|
return env.credentials.list(id)
|
|
60
59
|
}
|
|
@@ -223,7 +222,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
223
222
|
return { id, name, accounts }
|
|
224
223
|
},
|
|
225
224
|
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
226
|
-
getAlias: (): string => id,
|
|
227
225
|
async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
|
|
228
226
|
const key = input.apiKey.trim()
|
|
229
227
|
if (key === '') return { ok: false, error: 'API key 不能为空' }
|
|
@@ -246,18 +244,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
246
244
|
env.credentials.remove(id, uid)
|
|
247
245
|
return true
|
|
248
246
|
},
|
|
249
|
-
lastError: (): string | undefined => lastErr,
|
|
250
247
|
/** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
251
248
|
async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
252
249
|
const base = stripAlias(req.model, currentAlias())
|
|
253
250
|
if (!(await allModels(false)).some((m) => m.id === base)) {
|
|
254
|
-
|
|
255
|
-
return { ok: false, state: 'no_such_model', message:
|
|
251
|
+
const msg = `unknown model ${JSON.stringify(req.model)}`
|
|
252
|
+
return { ok: false, state: 'no_such_model', message: msg }
|
|
256
253
|
}
|
|
257
254
|
const acct = getKey(uid)
|
|
258
255
|
if (acct === undefined) {
|
|
259
|
-
|
|
260
|
-
return { ok: false, state: 'no_such_model', message:
|
|
256
|
+
const msg = `unknown account ${JSON.stringify(uid)}`
|
|
257
|
+
return { ok: false, state: 'no_such_model', message: msg }
|
|
261
258
|
}
|
|
262
259
|
|
|
263
260
|
let body = req.rawBody
|
|
@@ -278,33 +275,33 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
278
275
|
signal: AbortSignal.timeout(120000),
|
|
279
276
|
})
|
|
280
277
|
} catch (err) {
|
|
281
|
-
|
|
282
|
-
return { ok: false, state: 'transport', message:
|
|
278
|
+
const msg = (err as Error).message
|
|
279
|
+
return { ok: false, state: 'transport', message: msg }
|
|
283
280
|
}
|
|
284
281
|
|
|
285
282
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
286
283
|
const text = await upstream.text().catch(() => '')
|
|
287
|
-
|
|
284
|
+
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
288
285
|
const state: AccountState =
|
|
289
286
|
upstream.status === 429 ? 'rate_limit'
|
|
290
287
|
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
291
288
|
: upstream.status === 404 ? 'unavailable'
|
|
292
289
|
: 'unknown'
|
|
293
|
-
return { ok: false, state, message:
|
|
290
|
+
return { ok: false, state, message: msg }
|
|
294
291
|
}
|
|
295
292
|
|
|
296
293
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
297
294
|
if (req.stream) {
|
|
298
295
|
if (!upstream.body) {
|
|
299
|
-
|
|
300
|
-
return { ok: false, state: 'transport', message:
|
|
296
|
+
const msg = 'nvidia upstream: empty stream body'
|
|
297
|
+
return { ok: false, state: 'transport', message: msg }
|
|
301
298
|
}
|
|
302
299
|
return { ok: true, stream: upstream.body }
|
|
303
300
|
}
|
|
304
301
|
const text = await upstream.text().catch(() => '')
|
|
305
302
|
if (text === '') {
|
|
306
|
-
|
|
307
|
-
return { ok: false, state: 'transport', message:
|
|
303
|
+
const msg = 'nvidia upstream: empty body'
|
|
304
|
+
return { ok: false, state: 'transport', message: msg }
|
|
308
305
|
}
|
|
309
306
|
return { ok: true, status: upstream.status, body: text }
|
|
310
307
|
},
|
|
@@ -30,7 +30,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
30
30
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
31
31
|
let modelsCache: ModelInfo[] | undefined
|
|
32
32
|
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
33
|
-
let lastErr: string | undefined
|
|
34
33
|
|
|
35
34
|
async function refreshModels(): Promise<ModelInfo[]> {
|
|
36
35
|
try {
|
|
@@ -81,15 +80,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
81
80
|
icon,
|
|
82
81
|
status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
|
|
83
82
|
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
84
|
-
getAlias: (): string => id,
|
|
85
83
|
// testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
|
|
86
|
-
lastError: (): string | undefined => lastErr,
|
|
87
84
|
/** 无账号供应商:uid 恒为空,只认 free 模型。 */
|
|
88
85
|
async chatOnce(_uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
89
86
|
// 只认 free 模型,其他模型交给别的供应商
|
|
90
87
|
if (!isFreeModel(req.model)) {
|
|
91
|
-
|
|
92
|
-
return { ok: false, state: 'no_such_model', message:
|
|
88
|
+
const msg = `unknown free model ${JSON.stringify(req.model)}`
|
|
89
|
+
return { ok: false, state: 'no_such_model', message: msg }
|
|
93
90
|
}
|
|
94
91
|
// 模型名规范化:剥 alias 前缀,body.model 用裸 id
|
|
95
92
|
let body = req.rawBody
|
|
@@ -115,24 +112,24 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
115
112
|
signal: AbortSignal.timeout(120000),
|
|
116
113
|
})
|
|
117
114
|
} catch (err) {
|
|
118
|
-
|
|
119
|
-
return { ok: false, state: 'transport', message:
|
|
115
|
+
const msg = `opencode upstream: ${(err as Error).message}`
|
|
116
|
+
return { ok: false, state: 'transport', message: msg }
|
|
120
117
|
}
|
|
121
118
|
|
|
122
119
|
// 非 2xx:按状态归类,核心据此冷却/换号(本供应商无号可换,直接放弃)
|
|
123
120
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
124
121
|
const text = await upstream.text().catch(() => '')
|
|
125
|
-
|
|
122
|
+
const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
|
|
126
123
|
const state: AccountState =
|
|
127
124
|
upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
|
|
128
|
-
return { ok: false, state, message:
|
|
125
|
+
return { ok: false, state, message: msg }
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
// 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
|
|
132
129
|
if (req.stream) {
|
|
133
130
|
if (!upstream.body) {
|
|
134
|
-
|
|
135
|
-
return { ok: false, state: 'transport', message:
|
|
131
|
+
const msg = 'opencode upstream: empty stream body'
|
|
132
|
+
return { ok: false, state: 'transport', message: msg }
|
|
136
133
|
}
|
|
137
134
|
return { ok: true, stream: upstream.body }
|
|
138
135
|
}
|
|
@@ -140,8 +137,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
140
137
|
// 非流式:透传 JSON(空 body 视为上游异常)
|
|
141
138
|
const text = await upstream.text().catch(() => '')
|
|
142
139
|
if (text === '') {
|
|
143
|
-
|
|
144
|
-
return { ok: false, state: 'transport', message:
|
|
140
|
+
const msg = 'opencode upstream: empty body'
|
|
141
|
+
return { ok: false, state: 'transport', message: msg }
|
|
145
142
|
}
|
|
146
143
|
return { ok: true, status: upstream.status, body: text }
|
|
147
144
|
},
|
|
@@ -50,7 +50,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
50
50
|
let modelsCache: ModelInfo[] | undefined
|
|
51
51
|
let freeIds = new Set<string>()
|
|
52
52
|
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
53
|
-
let lastErr: string | undefined
|
|
54
53
|
function listKeys(): string[] {
|
|
55
54
|
return env.credentials.list(id)
|
|
56
55
|
}
|
|
@@ -121,7 +120,6 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
121
120
|
return { id, name, accounts }
|
|
122
121
|
},
|
|
123
122
|
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
124
|
-
getAlias: (): string => id,
|
|
125
123
|
async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
|
|
126
124
|
const key = input.apiKey.trim()
|
|
127
125
|
if (key === '') return { ok: false, error: 'API key 不能为空' }
|
|
@@ -151,18 +149,17 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
151
149
|
env.credentials.remove(id, uid)
|
|
152
150
|
return true
|
|
153
151
|
},
|
|
154
|
-
lastError: (): string | undefined => lastErr,
|
|
155
152
|
/** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
156
153
|
async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
157
154
|
const base = stripAlias(req.model, currentAlias())
|
|
158
155
|
if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
|
|
159
|
-
|
|
160
|
-
return { ok: false, state: 'no_such_model', message:
|
|
156
|
+
const msg = `unknown free model ${JSON.stringify(req.model)}`
|
|
157
|
+
return { ok: false, state: 'no_such_model', message: msg }
|
|
161
158
|
}
|
|
162
159
|
const acct = getKey(uid)
|
|
163
160
|
if (acct === undefined) {
|
|
164
|
-
|
|
165
|
-
return { ok: false, state: 'no_such_model', message:
|
|
161
|
+
const msg = `unknown account ${JSON.stringify(uid)}`
|
|
162
|
+
return { ok: false, state: 'no_such_model', message: msg }
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
let body = req.rawBody
|
|
@@ -188,34 +185,34 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
188
185
|
signal: AbortSignal.timeout(120000),
|
|
189
186
|
})
|
|
190
187
|
} catch (err) {
|
|
191
|
-
|
|
192
|
-
return { ok: false, state: 'transport', message:
|
|
188
|
+
const msg = (err as Error).message
|
|
189
|
+
return { ok: false, state: 'transport', message: msg }
|
|
193
190
|
}
|
|
194
191
|
|
|
195
192
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
196
193
|
const text = await upstream.text().catch(() => '')
|
|
197
|
-
|
|
194
|
+
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
198
195
|
// 401/403 = 这个 key 本身不可用;429 = 限流;其余归为说不清
|
|
199
196
|
const state: AccountState =
|
|
200
197
|
upstream.status === 429 ? 'rate_limit'
|
|
201
198
|
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
202
199
|
: upstream.status === 404 ? 'unavailable'
|
|
203
200
|
: 'unknown'
|
|
204
|
-
return { ok: false, state, message:
|
|
201
|
+
return { ok: false, state, message: msg }
|
|
205
202
|
}
|
|
206
203
|
|
|
207
204
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
208
205
|
if (req.stream) {
|
|
209
206
|
if (!upstream.body) {
|
|
210
|
-
|
|
211
|
-
return { ok: false, state: 'transport', message:
|
|
207
|
+
const msg = 'openrouter upstream: empty stream body'
|
|
208
|
+
return { ok: false, state: 'transport', message: msg }
|
|
212
209
|
}
|
|
213
210
|
return { ok: true, stream: upstream.body }
|
|
214
211
|
}
|
|
215
212
|
const text = await upstream.text().catch(() => '')
|
|
216
213
|
if (text === '') {
|
|
217
|
-
|
|
218
|
-
return { ok: false, state: 'transport', message:
|
|
214
|
+
const msg = 'openrouter upstream: empty body'
|
|
215
|
+
return { ok: false, state: 'transport', message: msg }
|
|
219
216
|
}
|
|
220
217
|
return { ok: true, status: upstream.status, body: text }
|
|
221
218
|
},
|
|
@@ -327,7 +327,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
327
327
|
return
|
|
328
328
|
}
|
|
329
329
|
const body = JSON.parse(await readBody(req)) as { uid?: string }
|
|
330
|
-
const
|
|
330
|
+
const uid = body.uid ?? ''
|
|
331
|
+
const ok = await s.removeLink(uid)
|
|
332
|
+
// 链接没了,它的积分缓存也得跟着走(不然删号再重登会顶着一个旧数字)
|
|
333
|
+
if (ok) store.clearCredits(s.id, uid)
|
|
331
334
|
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'link not found' })
|
|
332
335
|
},
|
|
333
336
|
})
|